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. --- .gitignore | 3 - README.md | 66 - docs/gleam.js | 209 --- docs/highlightjs-gleam.js | 109 -- docs/index.css | 698 --------- docs/index.html | 337 ----- docs/lustre.html | 626 -------- docs/lustre/attribute.html | 1110 --------------- docs/lustre/cmd.html | 444 ------ docs/lustre/element.html | 2753 ------------------------------------ docs/lustre/event.html | 661 --------- gleam.toml | 13 - lib/README.md | 66 + lib/gleam.toml | 13 + lib/manifest.toml | 11 + lib/package-lock.json | 201 +++ lib/package.json | 10 + 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 +++ lib/test/examples/components.gleam | 102 ++ lib/test/examples/components.html | 17 + lib/test/examples/counter.gleam | 56 + lib/test/examples/counter.html | 17 + lib/test/examples/index.html | 27 + lib/test/examples/input.gleam | 132 ++ lib/test/examples/input.html | 54 + lib/test/examples/nested.gleam | 57 + lib/test/examples/nested.html | 17 + lib/test/examples/svg.gleam | 107 ++ lib/test/examples/svg.html | 17 + manifest.toml | 11 - package-lock.json | 201 --- package.json | 10 - src/lustre.ffi.mjs | 206 --- src/lustre.gleam | 254 ---- src/lustre/attribute.gleam | 408 ------ src/lustre/effect.gleam | 67 - src/lustre/element.gleam | 126 -- src/lustre/element/html.gleam | 1197 ---------------- src/lustre/element/svg.gleam | 351 ----- src/lustre/event.gleam | 184 --- src/runtime.ffi.mjs | 230 --- test/examples/components.gleam | 102 -- test/examples/components.html | 17 - test/examples/counter.gleam | 56 - test/examples/counter.html | 17 - test/examples/index.html | 27 - test/examples/input.gleam | 132 -- test/examples/input.html | 54 - test/examples/nested.gleam | 57 - test/examples/nested.html | 17 - test/examples/svg.gleam | 107 -- test/examples/svg.html | 17 - 60 files changed, 3927 insertions(+), 10877 deletions(-) delete mode 100644 README.md delete mode 100644 docs/gleam.js delete mode 100644 docs/highlightjs-gleam.js delete mode 100644 docs/index.css delete mode 100644 docs/index.html delete mode 100644 docs/lustre.html delete mode 100644 docs/lustre/attribute.html delete mode 100644 docs/lustre/cmd.html delete mode 100644 docs/lustre/element.html delete mode 100644 docs/lustre/event.html delete mode 100644 gleam.toml create mode 100644 lib/README.md create mode 100644 lib/gleam.toml create mode 100644 lib/manifest.toml create mode 100644 lib/package-lock.json create mode 100644 lib/package.json 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 create mode 100644 lib/test/examples/components.gleam create mode 100644 lib/test/examples/components.html create mode 100644 lib/test/examples/counter.gleam create mode 100644 lib/test/examples/counter.html create mode 100644 lib/test/examples/index.html create mode 100644 lib/test/examples/input.gleam create mode 100644 lib/test/examples/input.html create mode 100644 lib/test/examples/nested.gleam create mode 100644 lib/test/examples/nested.html create mode 100644 lib/test/examples/svg.gleam create mode 100644 lib/test/examples/svg.html delete mode 100644 manifest.toml delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src/lustre.ffi.mjs delete mode 100644 src/lustre.gleam delete mode 100644 src/lustre/attribute.gleam delete mode 100644 src/lustre/effect.gleam delete mode 100644 src/lustre/element.gleam delete mode 100644 src/lustre/element/html.gleam delete mode 100644 src/lustre/element/svg.gleam delete mode 100644 src/lustre/event.gleam delete mode 100644 src/runtime.ffi.mjs delete mode 100644 test/examples/components.gleam delete mode 100644 test/examples/components.html delete mode 100644 test/examples/counter.gleam delete mode 100644 test/examples/counter.html delete mode 100644 test/examples/index.html delete mode 100644 test/examples/input.gleam delete mode 100644 test/examples/input.html delete mode 100644 test/examples/nested.gleam delete mode 100644 test/examples/nested.html delete mode 100644 test/examples/svg.gleam delete mode 100644 test/examples/svg.html diff --git a/.gitignore b/.gitignore index 0af723d..3976dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,3 @@ erl_crash.dump .vscode node_modules -.parcel-cache - -dist \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index b6d111c..0000000 --- a/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Lustre - -An Elm-inspired framework for building web apps in Gleam! - ---- - -[![Package Version](https://img.shields.io/hexpm/v/lustre)](https://hex.pm/packages/lustre) -[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/lustre/) - -```gleam -import gleam/int -import lustre -import lustre/element.{button, div, p, text} -import lustre/event.{on_click} -import lustre/cmd - -pub fn main() { - let app = lustre.simple(init, update, render) - let assert Ok(_) = lustre.start(app, "#app") - - Nil -} - -fn init() { - 0 -} - -type Msg { - Incr - Decr -} - -fn update(state, msg) { - case msg { - Incr -> state + 1 - Decr -> state - 1 - } -} - -fn render(state) { - div( - [], - [ - button([on_click(Decr)], [text("-")]), - p([], [text(int.to_string(state))]), - button([on_click(Incr)], [text("+")]), - ], - ) -} -``` - ---- - -❗️ This package relies on Gleam's JavaScript FFI and is intended to be run in -the browser. **It will not work if your are targetting Node.js or Erlang.** - ---- - -## Installation - -Lustre is available on [Hex](https://hex.pm/packages/lustre). You can install -it like any other Hex package: - -```sh -$ gleam add lustre -``` diff --git a/docs/gleam.js b/docs/gleam.js deleted file mode 100644 index 73530ce..0000000 --- a/docs/gleam.js +++ /dev/null @@ -1,209 +0,0 @@ -"use strict"; - -window.Gleam = function() { - /* Global Object */ - const self = {}; - - /* Public Properties */ - - self.hashOffset = undefined; - - /* Public Methods */ - - self.getProperty = function(property) { - let value; - try { - value = localStorage.getItem(`Gleam.${property}`); - } - catch (_error) {} - if (-1 < [null, undefined].indexOf(value)) { - return gleamConfig[property].values[0].value; - } - return value; - }; - - self.icons = function() { - return Array.from(arguments).reduce( - (acc, name) => - `${acc} - `, - "" - ); - } - - self.scrollToHash = function() { - const locationHash = arguments[0] || window.location.hash; - const query = locationHash ? locationHash : "body"; - const hashTop = document.querySelector(query).offsetTop; - window.scrollTo(0, hashTop - self.hashOffset); - return locationHash; - }; - - self.toggleSidebar = function() { - const previousState = - bodyClasses.contains("drawer-open") ? "open" : "closed"; - - let state; - if (0 < arguments.length) { - state = false === arguments[0] ? "closed" : "open"; - } - else { - state = "open" === previousState ? "closed" : "open"; - } - - bodyClasses.remove(`drawer-${previousState}`); - bodyClasses.add(`drawer-${state}`); - - if ("open" === state) { - document.addEventListener("click", closeSidebar, false); - } - }; - - /* Private Properties */ - - const html = document.documentElement; - const body = document.body; - const bodyClasses = body.classList; - const sidebar = document.querySelector(".sidebar"); - const sidebarToggles = document.querySelectorAll(".sidebar-toggle"); - const displayControls = document.createElement("div"); - - displayControls.classList.add("display-controls"); - sidebar.appendChild(displayControls); - - /* Private Methods */ - - const initProperty = function(property) { - const config = gleamConfig[property]; - - displayControls.insertAdjacentHTML( - "beforeend", - config.values.reduce( - (acc, item, index) => { - const tooltip = - item.label - ? `alt="${item.label}" title="${item.label}"` - : ""; - let inner; - if (item.icons) { - inner = self.icons(...item.icons); - } - else if (item.label) { - inner = item.label; - } - else { - inner = ""; - } - return ` - ${acc} - - ${inner} - - `; - }, - ` - ` - ); - - setProperty(null, property, function() { - return self.getProperty(property); - }); - }; - - const setProperty = function(_event, property) { - const previousValue = self.getProperty(property); - - const update = - 2 < arguments.length ? arguments[2] : gleamConfig[property].update; - const value = update(); - - try { - localStorage.setItem("Gleam." + property, value); - } - catch (_error) {} - - bodyClasses.remove(`${property}-${previousValue}`); - bodyClasses.add(`${property}-${value}`); - - const isDefault = value === gleamConfig[property].values[0].value; - const toggleClasses = - document.querySelector(`#${property}-toggle`).classList; - toggleClasses.remove(`toggle-${isDefault ? 1 : 0}`); - toggleClasses.add(`toggle-${isDefault ? 0 : 1}`); - - try { - gleamConfig[property].callback(value); - } - catch(_error) {} - - return value; - } - - const setHashOffset = function() { - const el = document.createElement("div"); - el.style.cssText = - ` - height: var(--hash-offset); - pointer-events: none; - position: absolute; - visibility: hidden; - width: 0; - `; - body.appendChild(el); - self.hashOffset = parseInt( - getComputedStyle(el).getPropertyValue("height") || "0" - ); - body.removeChild(el); - }; - - const closeSidebar = function(event) { - if (! event.target.closest(".sidebar-toggle")) { - document.removeEventListener("click", closeSidebar, false); - self.toggleSidebar(false); - } - }; - - const init = function() { - for (const property in gleamConfig) { - initProperty(property); - const toggle = document.querySelector(`#${property}-toggle`); - toggle.addEventListener("click", function(event) { - setProperty(event, property); - }); - } - - sidebarToggles.forEach(function(sidebarToggle) { - sidebarToggle.addEventListener("click", function(event) { - event.preventDefault(); - self.toggleSidebar(); - }); - }); - - setHashOffset(); - window.addEventListener("load", function(_event) { - self.scrollToHash(); - }); - window.addEventListener("hashchange", function(_event) { - self.scrollToHash(); - }); - - document.querySelectorAll(` - .module-name > a, - .member-name a[href^='#'] - `).forEach(function(title) { - title.innerHTML = - title.innerHTML.replace(/([A-Z])|([_/])/g, "$2$1"); - }); - }; - - /* Initialise */ - - init(); - - return self; -}(); diff --git a/docs/highlightjs-gleam.js b/docs/highlightjs-gleam.js deleted file mode 100644 index 292b8ca..0000000 --- a/docs/highlightjs-gleam.js +++ /dev/null @@ -1,109 +0,0 @@ -hljs.registerLanguage("gleam", function (hljs) { - const KEYWORDS = - "as assert case const external fn if import let " + - "opaque pub todo try tuple type"; - const STRING = { - className: "string", - variants: [{ begin: /"/, end: /"/ }], - contains: [hljs.BACKSLASH_ESCAPE], - relevance: 0, - }; - const NAME = { - className: "variable", - begin: "\\b[a-z][a-z0-9_]*\\b", - relevance: 0, - }; - const DISCARD_NAME = { - className: "comment", - begin: "\\b_[a-z][a-z0-9_]*\\b", - relevance: 0, - }; - const NUMBER = { - className: "number", - variants: [ - { - // binary - begin: "\\b0[bB](?:_?[01]+)+", - }, - { - // octal - begin: "\\b0[oO](?:_?[0-7]+)+", - }, - { - // hex - begin: "\\b0[xX](?:_?[0-9a-fA-F]+)+", - }, - { - // dec, float - begin: "\\b\\d(?:_?\\d+)*(?:\\.(?:\\d(?:_?\\d+)*)*)?", - }, - ], - relevance: 0, - }; - - return { - name: "Gleam", - aliases: ["gleam"], - contains: [ - hljs.C_LINE_COMMENT_MODE, - STRING, - { - // bit string - begin: "<<", - end: ">>", - contains: [ - { - className: "keyword", - beginKeywords: - "binary bytes int float bit_string bits utf8 utf16 utf32 " + - "utf8_codepoint utf16_codepoint utf32_codepoint signed unsigned " + - "big little native unit size", - }, - KEYWORDS, - STRING, - NAME, - DISCARD_NAME, - NUMBER, - ], - relevance: 10, - }, - { - className: "function", - beginKeywords: "fn", - end: "\\(", - excludeEnd: true, - contains: [ - { - className: "title", - begin: "[a-z][a-z0-9_]*\\w*", - relevance: 0, - }, - ], - }, - { - className: "keyword", - beginKeywords: KEYWORDS, - }, - { - // Type names and constructors - className: "title", - begin: "\\b[A-Z][A-Za-z0-9]*\\b", - relevance: 0, - }, - { - className: "operator", - begin: "[+\\-*/%!=<>&|.]+", - relevance: 0, - }, - NAME, - DISCARD_NAME, - NUMBER, - ], - }; -}); -document.querySelectorAll("pre code").forEach(block => { - if (block.className === "") { - block.classList.add("gleam"); - } - hljs.highlightBlock(block); -}); diff --git a/docs/index.css b/docs/index.css deleted file mode 100644 index ce095d6..0000000 --- a/docs/index.css +++ /dev/null @@ -1,698 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Karla:wght@400;700&family=Ubuntu+Mono&display=swap"); - -:root { - /* Colours */ - --black: #2a2020; - --hard-black: #000; - --pink: #ffaff3; - --hot-pink: #d900b8; - --white: #fff; - --pink-white: #fff8fe; - --mid-grey: #dfe2e5; - --light-grey: #f5f5f5; - --boi-blue: #a6f0fc; - - /* Derived colours */ - --text: var(--black); - --background: var(--white); - --accented-background: var(--pink-white); - --code-border: var(--pink); - --code-background: var(--light-grey); - --table-border: var(--mid-grey); - --table-background: var(--pink-white); - --links: var(--hot-pink); - --accent: var(--pink); - - /* Sizes */ - --content-width: 680px; - --header-height: 60px; - --hash-offset: calc(var(--header-height) * 1.67); - --sidebar-width: 240px; - --gap: 24px; - --small-gap: calc(var(--gap) / 2); - --tiny-gap: calc(var(--small-gap) / 2); - --large-gap: calc(var(--gap) * 2); - --sidebar-toggle-size: 33px; - - /* etc */ - --shadow: - 0 0 0 1px rgba(50, 50, 93, .075), - 0 0 1px #e9ecef, - 0 2px 4px -2px rgba(138, 141, 151, .6); - --nav-shadow: 0 0 6px 2px rgba(0, 0, 0, .1); -} - -* { - box-sizing: border-box; -} - -body, -html { - padding: 0; - margin: 0; - font-family: "Karla", sans-serif; - font-size: 17px; - line-height: 1.4; - position: relative; - min-height: 100vh; - word-break: break-word; -} - -html { - /* This is necessary so hash targets appear below the fixed header */ - scroll-padding-top: var(--hash-offset); -} - -a, -a:visited { - color: var(--links); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -button, -select { - background: transparent; - border: 0 none; - cursor: pointer; - font-family: inherit; - font-size: 100%; - line-height: 1.15; - margin: 0; - text-transform: none; -} - -button::-moz-focus-inner { - border-style: none; - padding: 0; -} - -button:-moz-focusring { - outline: 1px dotted ButtonText; -} - -button { - -webkit-appearance: button; - line-height: 1; - margin: 0; - overflow: visible; - padding: 0; -} - -button:active, -select:active { - outline: 0 none; -} - -li { - margin-bottom: 4px; -} - -p { - margin: var(--small-gap) 0; -} - -.rendered-markdown h1, -.rendered-markdown h2, -.rendered-markdown h3, -.rendered-markdown h4, -.rendered-markdown h5 { - font-size: 1.3rem; -} - -/* Code */ - -pre, -code { - font-family: "Ubuntu Mono", monospace; - line-height: 1.2; - background-color: var(--code-background); -} - -pre { - margin: var(--gap) 0; - border-radius: 1px; - overflow: auto; - box-shadow: var(--shadow); -} - -pre > code, -code.hljs { - padding: var(--small-gap) var(--gap); - background: transparent; -} - -p code { - margin: 0 2px; - border-radius: 3px; - padding: 0 0.2em; - color: var(--inline-code); -} - -/* Page layout */ - -.page { - display: flex; -} - -.content { - margin-left: var(--sidebar-width); - padding: calc(var(--header-height) + var(--gap)) var(--gap) 0 var(--gap); - width: calc(100% - var(--sidebar-width)); - max-width: var(--content-width); -} - -/* Page header */ - -.page-header { - box-shadow: var(--nav-shadow); - height: var(--header-height); - color: black; - color: var(--hard-black); - background-color: var(--pink); - display: flex; - padding: var(--small-gap) var(--gap); - position: fixed; - left: 0; - right: 0; - top: 0; - z-index: 300; -} - -.page-header h2 { - align-items: baseline; - display: flex; - margin: 0; - max-width: 100%; -} - -.page-header a, -.page-header a:visited { - color: black; - color: var(--hard-black); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sidebar-toggle { - display: none; - font-size: var(--sidebar-toggle-size); - opacity: 0; - transition: opacity 1s ease; -} - -.page-header .sidebar-toggle { - color: white; - color: var(--white); - margin: 0 var(--small-gap) 0 0; -} - -/* Version selector */ - -#project-version { - --half-small-gap: calc(var(--small-gap) / 2); - --icon-size: .75em; - flex-shrink: 0; - font-size: .9rem; - font-weight: normal; - margin-left: var(--half-small-gap); -} - -#project-version > span { - padding-left: var(--half-small-gap); -} - -#project-version form { - align-items: center; - display: inline-flex; - justify-content: flex-end; -} - -#project-version select { - appearance: none; - -webkit-appearance: none; - padding: .6rem calc(1.3 * var(--icon-size)) .6rem var(--half-small-gap); - position: relative; - z-index: 1; -} - -#project-version option { - background-color: var(--code-background); -} - -#project-version .icon { - font-size: var(--icon-size); - margin-left: calc(-1.65 * var(--icon-size)); -} - -/* Module doc */ - -.module-name > a, -.module-member-kind > a { - color: inherit; -} - -.module-name > a:hover, -.module-member-kind > a:hover { - text-decoration: none; -} - -.module-name > .icon-gleam-chasse, -.module-member-kind > .icon-gleam-chasse, -.module-member-kind > .icon-gleam-chasse-2 { - color: var(--pink); - display: block; - font-size: 1rem; - margin: var(--small-gap) 0 0; -} - -.module-name { - color: var(--hard-black); - margin: 0 0 var(--gap); - font-weight: 700; -} - -/* Sidebar */ - -.sidebar { - background-color: var(--background); - font-size: .95rem; - max-height: calc(100vh - var(--header-height)); - overflow-y: auto; - overscroll-behavior: contain; - padding-top: var(--gap); - padding-bottom: calc(3 * var(--gap)); - padding-left: var(--gap); - position: fixed; - top: var(--header-height); - transition: transform .5s ease; - width: var(--sidebar-width); - z-index: 100; -} - -.sidebar h2 { - margin: 0; -} - -.sidebar ul { - list-style: none; - margin: var(--small-gap) 0; - padding: 0; -} - -.sidebar li { - line-height: 1.2; - margin-bottom: 4px; -} - -.sidebar .sidebar-toggle { - color: var(--pink); - font-size: calc(.8 * var(--sidebar-toggle-size)); -} - -body.drawer-closed .label-open, -body.drawer-open .label-closed { - display: none; -} - -.display-controls { - display: flex; - flex-wrap: wrap; - margin-top: var(--small-gap); - padding-right: var(--gap); -} - -.display-controls .control { - margin: .5rem 0; -} - -.display-controls .control:not(:first-child) { - margin-left: 1rem; -} - -.toggle { - align-items: center; - display: flex; - font-size: .96rem; -} - -.toggle-0 .label:not(.label-0), -.toggle-1 .label:not(.label-1) { - display: none; -} - -.label { - display: flex; -} - -.label .icon { - margin: 0 .28rem; -} - -/* Module members (types, functions) */ - -.module-members { - margin-top: var(--large-gap); -} - -.module-member-kind { - font-size: 2rem; - color: var(--text); -} - -.member { - margin: var(--large-gap) 0; - padding-bottom: var(--gap); -} - -.member-name { - display: flex; - align-items: center; - justify-content: space-between; - border-left: 4px solid var(--accent); - padding: var(--small-gap) var(--gap); - background-color: var(--accented-background); -} - -.member-name h2 { - display: flex; - font-size: 1.5rem; - margin: 0; -} - -.member-name h2 a { - color: var(--text); -} - -.member-source { - align-self: baseline; - flex-shrink: 0; - line-height: calc(1.4 * 1.5rem); - margin: 0 0 0 var(--small-gap); -} - -/* Custom type constructors */ - -.constructor-list { - list-style: none; - padding: 0; -} - -.constructor-row { - align-items: center; - display: flex; -} - -.constructor-item { - margin-bottom: var(--small-gap); -} - -.constructor-argument-item { - display: flex; -} - -.constructor-argument-label { - flex-shrink: 0; -} - -.constructor-argument-doc { - margin-left: var(--gap); -} - -.constructor-argument-list { - margin-bottom: var(--small-gap); -} - -.constructor-item-docs { - margin-left: var(--large-gap); - margin-bottom: var(--gap); -} - -.constructor-item .icon { - flex-shrink: 0; - font-size: .7rem; - margin: 0 .88rem; -} - -.constructor-name { - box-shadow: unset; - margin: 0; -} - -.constructor-name > code { - padding: var(--small-gap); -} - -/* Tables */ - -table { - border-spacing: 0; - border-collapse: collapse; -} - -table td, -table th { - padding: 6px 13px; - border: 1px solid var(--table-border); -} - -table tr:nth-child(2n) { - background-color: var(--table-background); -} - -/* Footer */ - -.pride { - width: 100%; - display: none; - flex-direction: row; - position: absolute; - bottom: 0; - z-index: 100; -} - -.show-pride .pride { - display: flex; -} - -.show-pride .sidebar { - margin-bottom: var(--gap); -} - -.pride div { - flex: 1; - text-align: center; - padding: var(--tiny-gap); -} - -.pride .white { - background-color: var(--white); -} -.pride .pink { - background-color: var(--pink); -} -.pride .blue { - background-color: var(--boi-blue); -} - -.pride-button { - position: absolute; - right: 2px; - bottom: 2px; - opacity: .2; - font-size: .9rem; -} - -.pride-button { - text-decoration: none; - cursor: default; -} - -/* Icons */ - -.svg-lib { - height: 0; - overflow: hidden; - position: absolute; - width: 0; -} - -.icon { - display: inline-block; - fill: currentColor; - height: 1em; - stroke: currentColor; - stroke-width: 0; - width: 1em; -} - -.icon-gleam-chasse { - width: 8.182em; -} - -.icon-gleam-chasse-2 { - width: 4.909em; -} - -/* Pre-Wrap Option */ - -body.prewrap-on code, -body.prewrap-on pre { - white-space: pre-wrap; -} - -/* Dark Theme Option */ - -body.theme-dark { - /* Colour palette adapted from: - * https://github.com/dustypomerleau/yarra-valley - */ - - --argument-atom: #c651e5; - --class-module: #ff89b5; - --comment: #7e818b; - --escape: #7cdf89; - --function-call: #abb8c0; - --function-definition: #8af899; - --interpolation-regex: #ee37aa; - --keyword-operator: #ff9d35; - --number-boolean: #f14360; - --object: #99c2eb; - --punctuation: #4ce7ff; - --string: #aecc00; - - --inline-code: #ff9d35; - - --bg: #292d3e; - --bg-tint-1: #3e4251; --bg-tint-2: #535664; --bg-tint-3: #696c77; --bg-tint-4: #7e818b; - --bg-shade-1: #242837; --bg-shade-2: #202431; --bg-shade-3: #1c1f2b; - --bg-mono-1: #33384d; --bg-mono-2: #3d435d; --bg-mono-3: #474e6c; --bg-mono-4: #51597b; - - --fg: #cac0a9; - --fg-tint-1: #fdf2d8; --fg-tint-2: #fdf3dc; --fg-tint-3: #fdf5e0; - --fg-shade-1: #e3d8be; --fg-shade-2: #cac0a9; --fg-shade-3: #b1a894; --fg-shade-4: #97907f; - - --orange-shade-1: #e58d2f; --orange-shade-2: #cc7d2a; --orange-shade-3: #b26d25; - - --taupe-mono-1: #fdf1d4; --taupe-mono-2: #fce9bc; --taupe-mono-3: #fbe1a3; - - /* Theme Overrides */ - - --accent: var(--pink); - --accented-background: var(--bg-shade-1); - --background: var(--bg); - --code-background: var(--bg-shade-2); - --table-background: var(--bg-mono-1); - --hard-black: var(--taupe-mono-1); - --links: var(--pink); - --text: var(--taupe-mono-1); - - --shadow: - 0 0 0 1px rgba(50, 50, 93, .075), - 0 0 1px var(--fg-shade-3), - 0 2px 4px -2px rgba(138, 141, 151, .2); - --nav-shadow: 0 0 5px 5px rgba(0, 0, 0, .1); -} - -body.theme-dark { - background-color: var(--bg); - color: var(--fg-shade-1); -} - -body.theme-dark .page-header { - background-color: var(--bg-mono-1); -} - -body.theme-dark .page-header h2 { - color: var(--fg-shade-1); -} - - -body.theme-dark .page-header a, -body.theme-dark .page-header a:visited { - color: var(--pink); -} - -body.theme-dark .page-header .sidebar-toggle { - color: var(--fg-shade-1); -} - -body.theme-dark #project-version select, -body.theme-dark .control { - color: var(--fg-shade-1); -} - -body.theme-dark .module-name { - color: var(--taupe-mono-1); -} - -body.theme-dark .pride { - color: var(--bg-shade-3); -} - -body.theme-dark .pride .white { - background-color: var(--fg-shade-1); -} - -body.theme-dark .pride .pink { - background-color: var(--argument-atom); -} - -body.theme-dark .pride .blue { - background-color: var(--punctuation); -} - -/* Medium and larger displays */ -@media (min-width: 680px) { - #prewrap-toggle { - display: none; - } -} - -/* Small displays */ -@media (max-width: 920px) { - .page-header { - padding-left: var(--small-gap); - padding-right: var(--small-gap); - } - - .page-header h2 { - max-width: calc(100% - var(--sidebar-toggle-size) - var(--small-gap)); - } - - .content { - width: 100%; - max-width: unset; - margin-left: unset; - } - - .sidebar { - box-shadow: var(--nav-shadow); - height: 100vh; - max-height: unset; - top: 0; - transform: translate(calc(-10px - var(--sidebar-width))); - z-index: 500; - } - - body.drawer-open .sidebar { - transform: translate(0); - } - - .sidebar-toggle { - display: block; - opacity: 1; - } - - .sidebar .sidebar-toggle { - height: var(--sidebar-toggle-size); - position: absolute; - right: var(--small-gap); - top: var(--small-gap); - width: var(--sidebar-toggle-size); - } -} diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index f5fda5e..0000000 --- a/docs/index.html +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - lustre - - - - - - - - - - - - -
- - -
- -

Lustre

-

A framework for building create web apps – powered by Gleam and React!

-
-

Package Version -Hex Docs

-
import gleam/int
-import lustre
-import lustre/element.{ button, div, p, text }
-import lustre/event.{ dispatch, on_click }
-
-pub fn main () {
-    let app = lustre.application(0, update, render)
-    lustre.start(app, "#app")
-}
-
-type Action {
-    Incr
-    Decr
-}
-
-fn update (state, action) {
-    case action {
-        Incr -> state + 1
-        Decr -> state - 1
-    }
-}
-
-fn render (state) {
-    div([], [
-        button([ on_click(dispatch(Decr)) ], [ text("-") ]),
-        p([], [ text(int.to_string(state)) ]),
-        button([ on_click(dispatch(Incr)) ], [ text("+") ])
-    ])
-}
-
-
-

❗️ This package relies on Gleam’s JavaScript FFI and is intended to be run in -the browser. It will not work if your are targetting Node.js or Erlang.

-
-

Installation

-

If available on Hex, this package can be added to your Gleam project:

-
gleam add lustre
-
-

and its documentation can be found at https://hexdocs.pm/lustre. You will also -need to install react and react-dom from npm:

-
npm i react react-dom
-
-
-

Development

-

First, make sure you have both Gleam and Node.js installed, then:

-
npm i
-npm start
-
-

This sets up chokidar to watch our gleam source code and runs the compiler -whenever we make a change. It also starts a server that will serve the examples -located in test/example/.

- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/lustre.html b/docs/lustre.html deleted file mode 100644 index d0bbc53..0000000 --- a/docs/lustre.html +++ /dev/null @@ -1,626 +0,0 @@ - - - - - - lustre - lustre - - - - - - - - - - - - -
- - -
- -

- lustre - -

-

Lustre is a declarative framework for building Web apps in Gleam.

- - - -
-

- Types - -

- - -
-
-

- - App - -

- - - </> - - -
-
-

An App describes a Lustre application: what state it holds and what kind -of actions get dispatched to update that state. The only useful thing you can -do with an App is pass it to start.

-

You can construct an App from the two constructors exposed in this module: -basic and application. Although you can’t do -anything but 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.

-
                                      +--------+
-                                      |        |
-                                      | update |
-                                      |        |
-                                      +--------+
-                                        ^    |
-                                        |    | 
-                                 Action |    | #(State, Action)
-                                        |    |
-                                        |    v
-  +------+                    +------------------------+
-  |      |  #(State, Action)  |                        |
-  | init |------------------->|     Lustre Runtime     |
-  |      |                    |                        |
-  +------+                    +------------------------+
-                                        ^    |
-                                        |    |
-                                 Action |    | State
-                                        |    |
-                                        |    v
-                                      +--------+
-                                      |        |
-                                      | render |
-                                      |        |
-                                      +--------+
-
-

Someone please PR the Gleam docs generator to fix the monospace font, -thanks! 💖

-
-
pub opaque type App(state, action)
- - -
-
- -
- -
-
-
pub type Error {
-  ElementNotFound
-}
- -

- Constructors -

-
    - -
  • -
    - -
    ElementNotFound
    -
    - -
    - - - - -
    -
  • - -
- -
-
- -
- - - - - - -
-

- Functions - -

- -
- -
pub fn application(init: #(a, Cmd(b)), update: fn(a, b) ->
-    #(a, Cmd(b)), render: fn(a) -> Element(b)) -> App(a, b)
-

An evolution of a simple app that allows you to return a -Cmd from your init and updates. 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/cmd
-import lustre/element
-
-pub fn main () {
-    let init = #(0, tick())
-    let update = fn (state, action) {
-        case action {
-            Tick -> #(state + 1, tick())
-        }
-    }
-    let render = fn (state) {
-        element.div([], [
-            element.text("Count is: ")
-            element.text(state |> int.to_string |> element.text)
-        ])
-    }
-
-    let app = lustre.simple(init, update, render)
-    lustre.start(app, "#root")   
-}
-
-fn tick () -> Cmd(Action) {
-    cmd.from(fn (dispatch) {
-        setInterval(fn () {
-            dispatch(Tick)
-        }, 1000)
-    })
-}
-
-external fn set_timeout (f: fn () -> a, delay: Int) -> Nil 
-    = "" "window.setTimeout"
-
-
-
- -
- -
pub fn element(element: Element(a)) -> App(Nil, a)
-

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 or application -instead.

-
import lustre
-import lustre/element
-
-pub fn main () {
-    let app = lustre.element(
-        element.h1([], [
-            element.text("Hello, world!") 
-        ])
-    )
-
-    lustre.start(app, "#root")
-}
-
-
-
- -
- -
pub fn simple(init: a, update: fn(a, b) -> a, render: fn(a) ->
-    Element(b)) -> App(a, b)
-

If you start off with a simple [element](#element) app, you may find -yourself leaning on stateful elements -to manage state used throughout your app. If that’s the case or if you know -you need some global state from the get-go, you might want to construct a -simple app instead.

-

This is one app constructor that allows your HTML elements to dispatch actions -to update your program state.

-
import gleam/int
-import lustre
-import lustre/element
-import lustre/event.{ dispatch }
-
-type Action {
-    Incr
-    Decr
-}
-
-pub fn main () {
-    let init = 0
-    let update = fn (state, action) {
-        case action {
-            Incr -> state + 1
-            Decr -> state - 1
-        }
-    }
-    let render = fn (state) {
-        element.div([], [
-            element.button([ event.on_click(dispatch(Decr)) ], [
-                element.text("-")
-            ]),
-            element.text(state |> int.to_string |> element.text),
-            element.button([ event.on_click(dispatch(Incr)) ], [
-                element.text("+")
-            ])
-        ])
-    }
-
-    let app = lustre.simple(init, update, render)
-    lustre.start(app, "#root")
-}
-
-
-
- -
- -
pub fn start(app: App(a, b), selector: String) -> Result(
-  fn(b) -> Nil,
-  Error,
-)
-

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)
-}
-
-
-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/lustre/attribute.html b/docs/lustre/attribute.html deleted file mode 100644 index 0fd6856..0000000 --- a/docs/lustre/attribute.html +++ /dev/null @@ -1,1110 +0,0 @@ - - - - - - lustre/attribute - lustre - - - - - - - - - - - - -
- - -
- -

- lustre/attribute - -

- - - -
-

- Types - -

- - -
- -
-
-
pub opaque type Attribute(action)
- - -
-
- -
- - - - - - -
-

- Functions - -

- -
- -
pub fn accept(types: List(String)) -> Attribute(a)
-
-
- -
- -
pub fn accept_charset(types: List(String)) -> Attribute(a)
-
-
- -
- -
pub fn action(uri: String) -> Attribute(a)
-
-
- -
-
-

- - alt - -

- - - </> - - -
-
pub fn alt(text: String) -> Attribute(a)
-
-
- -
- -
pub fn attribute(name: String, value: String) -> Attribute(a)
-
-
- -
- -
pub fn autocomplete(should_autocomplete: Bool) -> Attribute(a)
-
-
- -
- -
pub fn autofocus(should_autofocus: Bool) -> Attribute(a)
-
-
- -
- -
pub fn autoplay(should_autoplay: Bool) -> Attribute(a)
-
-
- -
- -
pub fn checked(is_checked: Bool) -> Attribute(a)
-
-
- -
- -
pub fn class(name: String) -> Attribute(a)
-
-
- -
- -
pub fn classes(names: List(#(String, Bool))) -> Attribute(a)
-
-
- -
-
-

- - cols - -

- - - </> - - -
-
pub fn cols(val: Int) -> Attribute(a)
-
-
- -
- -
pub fn controls(visible: Bool) -> Attribute(a)
-
-
- -
- -
pub fn disabled(is_disabled: Bool) -> Attribute(a)
-
-
- -
- -
pub fn download(filename: String) -> Attribute(a)
-
-
- -
- -
pub fn event(name: String, handler: fn(Dynamic, fn(a) -> Nil) ->
-    Nil) -> Attribute(a)
-
-
- -
-
-

- - for - -

- - - </> - - -
-
pub fn for(id: String) -> Attribute(a)
-
-
- -
- -
pub fn height(val: Int) -> Attribute(a)
-
-
- -
-
-

- - href - -

- - - </> - - -
-
pub fn href(uri: String) -> Attribute(a)
-
-
- -
-
-

- - id - -

- - - </> - - -
-
pub fn id(name: String) -> Attribute(a)
-
-
- -
-
-

- - loop - -

- - - </> - - -
-
pub fn loop(should_loop: Bool) -> Attribute(a)
-
-
- -
-
-

- - max - -

- - - </> - - -
-
pub fn max(val: String) -> Attribute(a)
-
-
- -
-
-

- - min - -

- - - </> - - -
-
pub fn min(val: String) -> Attribute(a)
-
-
- -
-
-

- - name - -

- - - </> - - -
-
pub fn name(name: String) -> Attribute(a)
-
-
- -
- -
pub fn pattern(regex: String) -> Attribute(a)
-
-
- -
- -
pub fn placeholder(text: String) -> Attribute(a)
-
-
- -
- -
pub fn property(name: String, value: Dynamic) -> Attribute(a)
-
-
- -
- -
pub fn readonly(is_readonly: Bool) -> Attribute(a)
-
-
- -
-
-

- - rel - -

- - - </> - - -
-
pub fn rel(relationship: String) -> Attribute(a)
-
-
- -
- -
pub fn require(is_required: Bool) -> Attribute(a)
-
-
- -
-
-

- - rows - -

- - - </> - - -
-
pub fn rows(val: Int) -> Attribute(a)
-
-
- -
- -
pub fn selected(is_selected: Bool) -> Attribute(a)
-
-
- -
-
-

- - src - -

- - - </> - - -
-
pub fn src(uri: String) -> Attribute(a)
-
-
- -
-
-

- - step - -

- - - </> - - -
-
pub fn step(val: String) -> Attribute(a)
-
-
- -
- -
pub fn style(properties: List(#(String, String))) -> Attribute(a)
-
-
- -
- -
pub fn target(target: String) -> Attribute(a)
-
-
- -
- -
pub fn type_(name: String) -> Attribute(a)
-
-
- -
- -
pub fn value(val: Dynamic) -> Attribute(a)
-
-
- -
- -
pub fn width(val: Int) -> Attribute(a)
-
-
- -
-
-

- - wrap - -

- - - </> - - -
-
pub fn wrap(mode: String) -> Attribute(a)
-
-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/lustre/cmd.html b/docs/lustre/cmd.html deleted file mode 100644 index a89fbe8..0000000 --- a/docs/lustre/cmd.html +++ /dev/null @@ -1,444 +0,0 @@ - - - - - - lustre/cmd - lustre - - - - - - - - - - - - -
- - -
- -

- lustre/cmd - -

- - - -
-

- Types - -

- - -
-
-

- - Cmd - -

- - - </> - - -
-
-
-
pub opaque type Cmd(action)
- - -
-
- -
- - - - - - -
-

- Functions - -

- -
- -
pub fn batch(cmds: List(Cmd(a))) -> Cmd(a)
-
-
- -
-
-

- - from - -

- - - </> - - -
-
pub fn from(cmd: fn(fn(a) -> Nil) -> Nil) -> Cmd(a)
-
-
- -
-
-

- - map - -

- - - </> - - -
-
pub fn map(cmd: Cmd(a), f: fn(a) -> b) -> Cmd(b)
-
-
- -
-
-

- - none - -

- - - </> - - -
-
pub fn none() -> Cmd(a)
-
-
- -
- -
pub fn to_list(cmd: Cmd(a)) -> List(fn(fn(a) -> Nil) -> Nil)
-
-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/lustre/element.html b/docs/lustre/element.html deleted file mode 100644 index 24862ea..0000000 --- a/docs/lustre/element.html +++ /dev/null @@ -1,2753 +0,0 @@ - - - - - - lustre/element - lustre - - - - - - - - - - - - -
- - -
- -

- lustre/element - -

- - - -
-

- Types - -

- - -
- -
-
-
pub external type Element(action)
- - -
-
- -
- - - - - - -
-

- Functions - -

- -
-
-

- - a - -

- - - </> - - -
-
pub fn a(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - abbr - -

- - - </> - - -
-
pub fn abbr(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn address(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - area - -

- - - </> - - -
-
pub fn area(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn article(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn aside(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn audio(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - b - -

- - - </> - - -
-
pub fn b(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - base - -

- - - </> - - -
-
pub fn base(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - bdi - -

- - - </> - - -
-
pub fn bdi(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - bdo - -

- - - </> - - -
-
pub fn bdo(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn blockquote(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - body - -

- - - </> - - -
-
pub fn body(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - br - -

- - - </> - - -
-
pub fn br(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn button(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn canvas(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn caption(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - cite - -

- - - </> - - -
-
pub fn cite(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - code - -

- - - </> - - -
-
pub fn code(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - col - -

- - - </> - - -
-
pub fn col(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn colgroup(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn datalist(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - dd - -

- - - </> - - -
-
pub fn dd(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - del - -

- - - </> - - -
-
pub fn del(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn details(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - dfn - -

- - - </> - - -
-
pub fn dfn(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn dialog(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - div - -

- - - </> - - -
-
pub fn div(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - dl - -

- - - </> - - -
-
pub fn dl(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - dt - -

- - - </> - - -
-
pub fn dt(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - em - -

- - - </> - - -
-
pub fn em(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn embed(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn fieldset(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn figcaption(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn figure(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn footer(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - form - -

- - - </> - - -
-
pub fn form(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub external fn fragment(
-  children: List(Element(action)),
-) -> Element(action)
-

A fragment doesn’t appear in the DOM, but allows us to treat a list of elements -as if it were a single one.

-
-
- -
-
-

- - h1 - -

- - - </> - - -
-
pub fn h1(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - h2 - -

- - - </> - - -
-
pub fn h2(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - h3 - -

- - - </> - - -
-
pub fn h3(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - h4 - -

- - - </> - - -
-
pub fn h4(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - h5 - -

- - - </> - - -
-
pub fn h5(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - h6 - -

- - - </> - - -
-
pub fn h6(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
- - - - </> - - -
-
pub fn head(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn header(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - hr - -

- - - </> - - -
-
pub fn hr(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - html - -

- - - </> - - -
-
pub fn html(attributes: List(Attribute(a)), head: Element(a), body: Element(
-    a,
-  )) -> Element(a)
-
-
- -
-
-

- - i - -

- - - </> - - -
-
pub fn i(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn iframe(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - img - -

- - - </> - - -
-
pub fn img(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn input(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - ins - -

- - - </> - - -
-
pub fn ins(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - kbd - -

- - - </> - - -
-
pub fn kbd(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn label(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn legend(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - li - -

- - - </> - - -
-
pub fn li(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - main - -

- - - </> - - -
-
pub fn main(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - map - -

- - - </> - - -
-
pub external fn map(
-  element: Element(a),
-  f: fn(a) -> b,
-) -> Element(b)
-

Transforms the actions produced by some element.

-
-
- -
-
-

- - map_ - -

- - - </> - - -
-
pub fn map_(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - mark - -

- - - </> - - -
-
pub fn mark(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn mathml(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
- - - - </> - - -
-
pub fn menu(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - meta - -

- - - </> - - -
-
pub fn meta(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn meter(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
- - - - </> - - -
-
pub fn nav(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - node - -

- - - </> - - -
-
pub external fn node(
-  tag: String,
-  attributes: List(Attribute(action)),
-  children: List(Element(action)),
-) -> Element(action)
-

Construct a plain HTML element or registered Web Component by providing the -tag name, a list of attributes (including event handlers), and a list of -child elements.

-
-
- -
- -
pub fn noscript(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn object(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - ol - -

- - - </> - - -
-
pub fn ol(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn optgroup(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn option(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn output(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - p - -

- - - </> - - -
-
pub fn p(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn param(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn picture(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn portal(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - pre - -

- - - </> - - -
-
pub fn pre(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn progress(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - rp - -

- - - </> - - -
-
pub fn rp(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - rt - -

- - - </> - - -
-
pub fn rt(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - ruby - -

- - - </> - - -
-
pub fn ruby(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - s - -

- - - </> - - -
-
pub fn s(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - samp - -

- - - </> - - -
-
pub fn samp(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn section(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn select(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - slot - -

- - - </> - - -
-
pub fn slot(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn small(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn source(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
-
-

- - span - -

- - - </> - - -
-
pub fn span(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub external fn stateful(
-  init: state,
-  render: fn(state, fn(state) -> Nil) -> Element(action),
-) -> Element(action)
-

A stateful element is exactly what it sounds like: some element with local -encapsulated state! The render function we must provide is called with the -element’s current state as well as a function to set a new state. Whenever -that function is called, the element is re-rendered.

-

You might be wondering where the stateless version of this function is. -Those are just regular Gleam functions that return Elements!

-
-
- -
- -
pub fn strong(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn style(attributes: List(Attribute(a)), css: String) -> Element(
-  a,
-)
-
-
- -
-
-

- - sub - -

- - - </> - - -
-
pub fn sub(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn summary(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - sup - -

- - - </> - - -
-
pub fn sup(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - svg - -

- - - </> - - -
-
pub fn svg(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn table(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn tbody(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - td - -

- - - </> - - -
-
pub fn td(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn template(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - text - -

- - - </> - - -
-
pub external fn text(content: String) -> Element(action)
-

Render a Gleam string as an HTML text node.

-
-
- -
- -
pub fn textarea(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- -
pub fn tfoot(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - th - -

- - - </> - - -
-
pub fn th(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn thead(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - time - -

- - - </> - - -
-
pub fn time(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn title(attributes: List(Attribute(a)), name: String) -> Element(
-  a,
-)
-
-
- -
-
-

- - tr - -

- - - </> - - -
-
pub fn tr(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn track(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - u - -

- - - </> - - -
-
pub fn u(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - ul - -

- - - </> - - -
-
pub fn ul(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - var_ - -

- - - </> - - -
-
pub fn var_(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
- -
pub fn video(attributes: List(Attribute(a)), children: List(
-    Element(a),
-  )) -> Element(a)
-
-
- -
-
-

- - wbr - -

- - - </> - - -
-
pub fn wbr(attributes: List(Attribute(a))) -> Element(a)
-
-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/lustre/event.html b/docs/lustre/event.html deleted file mode 100644 index 66ba645..0000000 --- a/docs/lustre/event.html +++ /dev/null @@ -1,661 +0,0 @@ - - - - - - lustre/event - lustre - - - - - - - - - - - - -
- - -
- -

- lustre/event - -

- - - - - - - - - -
-

- Functions - -

- -
- -
pub fn dispatch(action: a) -> fn(fn(a) -> Nil) -> Nil
-
-
- -
-
-

- - on - -

- - - </> - - -
-
pub fn on(name: String, handler: fn(Dynamic, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_blur(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(a)
-
-
- -
- -
pub fn on_check(handler: fn(Bool, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_click(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(a)
-
-
- -
- -
pub fn on_focus(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(a)
-
-
- -
- -
pub fn on_input(handler: fn(String, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_keydown(handler: fn(String, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_keypress(handler: fn(String, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_keyup(handler: fn(String, fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_down(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_enter(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_leave(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_out(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_over(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_mouse_up(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(
-  a,
-)
-
-
- -
- -
pub fn on_submit(handler: fn(fn(a) -> Nil) -> Nil) -> Attribute(a)
-
-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/gleam.toml b/gleam.toml deleted file mode 100644 index 542b44c..0000000 --- a/gleam.toml +++ /dev/null @@ -1,13 +0,0 @@ -name = "lustre" -version = "3.0.0-rc.8" - -description = "An Elm-inspired framework for building web apps in Gleam!" -licences = ["MIT"] -links = [{ title = "Buy me a coffee?", href = "https://github.com/sponsors/hayleigh-dot-dev" }] -repository = { type = "github", user = "hayleigh-dot-dev", repo = "gleam-lustre" } -target = "javascript" - -[dependencies] -gleam_stdlib = "~> 0.30" -funtil = "~> 1.0" - diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..b6d111c --- /dev/null +++ b/lib/README.md @@ -0,0 +1,66 @@ +# Lustre + +An Elm-inspired framework for building web apps in Gleam! + +--- + +[![Package Version](https://img.shields.io/hexpm/v/lustre)](https://hex.pm/packages/lustre) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/lustre/) + +```gleam +import gleam/int +import lustre +import lustre/element.{button, div, p, text} +import lustre/event.{on_click} +import lustre/cmd + +pub fn main() { + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "#app") + + Nil +} + +fn init() { + 0 +} + +type Msg { + Incr + Decr +} + +fn update(state, msg) { + case msg { + Incr -> state + 1 + Decr -> state - 1 + } +} + +fn render(state) { + div( + [], + [ + button([on_click(Decr)], [text("-")]), + p([], [text(int.to_string(state))]), + button([on_click(Incr)], [text("+")]), + ], + ) +} +``` + +--- + +❗️ This package relies on Gleam's JavaScript FFI and is intended to be run in +the browser. **It will not work if your are targetting Node.js or Erlang.** + +--- + +## Installation + +Lustre is available on [Hex](https://hex.pm/packages/lustre). You can install +it like any other Hex package: + +```sh +$ gleam add lustre +``` diff --git a/lib/gleam.toml b/lib/gleam.toml new file mode 100644 index 0000000..542b44c --- /dev/null +++ b/lib/gleam.toml @@ -0,0 +1,13 @@ +name = "lustre" +version = "3.0.0-rc.8" + +description = "An Elm-inspired framework for building web apps in Gleam!" +licences = ["MIT"] +links = [{ title = "Buy me a coffee?", href = "https://github.com/sponsors/hayleigh-dot-dev" }] +repository = { type = "github", user = "hayleigh-dot-dev", repo = "gleam-lustre" } +target = "javascript" + +[dependencies] +gleam_stdlib = "~> 0.30" +funtil = "~> 1.0" + diff --git a/lib/manifest.toml b/lib/manifest.toml new file mode 100644 index 0000000..a5dc3b1 --- /dev/null +++ b/lib/manifest.toml @@ -0,0 +1,11 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "funtil", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "funtil", source = "hex", outer_checksum = "408E301240E6039FA0D9AB24E648FC176DFB82486835AFE23FE59B40222CEC9A" }, + { name = "gleam_stdlib", version = "0.30.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "03710B3DA047A3683117591707FCA19D32B980229DD8CE8B0603EB5B5144F6C3" }, +] + +[requirements] +funtil = { version = "~> 1.0" } +gleam_stdlib = { version = "~> 0.30" } diff --git a/lib/package-lock.json b/lib/package-lock.json new file mode 100644 index 0000000..7fafc3d --- /dev/null +++ b/lib/package-lock.json @@ -0,0 +1,201 @@ +{ + "name": "lustre-core", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "vite": "^4.4.2" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.11", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.18.11", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.11", + "@esbuild/android-arm64": "0.18.11", + "@esbuild/android-x64": "0.18.11", + "@esbuild/darwin-arm64": "0.18.11", + "@esbuild/darwin-x64": "0.18.11", + "@esbuild/freebsd-arm64": "0.18.11", + "@esbuild/freebsd-x64": "0.18.11", + "@esbuild/linux-arm": "0.18.11", + "@esbuild/linux-arm64": "0.18.11", + "@esbuild/linux-ia32": "0.18.11", + "@esbuild/linux-loong64": "0.18.11", + "@esbuild/linux-mips64el": "0.18.11", + "@esbuild/linux-ppc64": "0.18.11", + "@esbuild/linux-riscv64": "0.18.11", + "@esbuild/linux-s390x": "0.18.11", + "@esbuild/linux-x64": "0.18.11", + "@esbuild/netbsd-x64": "0.18.11", + "@esbuild/openbsd-x64": "0.18.11", + "@esbuild/sunos-x64": "0.18.11", + "@esbuild/win32-arm64": "0.18.11", + "@esbuild/win32-ia32": "0.18.11", + "@esbuild/win32-x64": "0.18.11" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.25", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "3.26.2", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "4.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.24", + "rollup": "^3.25.2" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 0000000..fdc091a --- /dev/null +++ b/lib/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "gleam build && vite serve ./test/examples" + }, + "devDependencies": { + "vite": "^4.4.2" + } +} 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 , , + + +
+ + diff --git a/lib/test/examples/counter.gleam b/lib/test/examples/counter.gleam new file mode 100644 index 0000000..759ebdf --- /dev/null +++ b/lib/test/examples/counter.gleam @@ -0,0 +1,56 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre +import lustre/element.{Element, text} +import lustre/element/html.{button, div, p} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") +} + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init() -> Model { + 0 +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr + Reset +} + +pub fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + Reset -> 0 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn render(model: Model) -> Element(Msg) { + div( + [], + [ + button([event.on_click(Incr)], [text("+")]), + button([event.on_click(Decr)], [text("-")]), + button([event.on_click(Reset)], [text("Reset")]), + p([], [text(int.to_string(model))]), + ], + ) +} diff --git a/lib/test/examples/counter.html b/lib/test/examples/counter.html new file mode 100644 index 0000000..2b120fd --- /dev/null +++ b/lib/test/examples/counter.html @@ -0,0 +1,17 @@ + + + + + + lustre | counter + + + + +
+ + diff --git a/lib/test/examples/index.html b/lib/test/examples/index.html new file mode 100644 index 0000000..70d4196 --- /dev/null +++ b/lib/test/examples/index.html @@ -0,0 +1,27 @@ + + + + + + lustre | examples + + + +
  • + input +
  • +
  • + counter +
  • +
  • + nested +
  • +
  • + svg +
  • +
  • + components +
  • +
    + + diff --git a/lib/test/examples/input.gleam b/lib/test/examples/input.gleam new file mode 100644 index 0000000..d59c0c9 --- /dev/null +++ b/lib/test/examples/input.gleam @@ -0,0 +1,132 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic +import gleam/string +import lustre +import lustre/attribute.{attribute} +import lustre/element.{Element, text} +import lustre/element/html.{div, input, label, pre} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(email: String, password: String, remember_me: Bool) +} + +fn init() -> Model { + Model(email: "", password: "", remember_me: False) +} + +// UPDATE ---------------------------------------------------------------------- + +type Msg { + Typed(Input, String) + Toggled(Control, Bool) +} + +type Input { + Email + Password +} + +type Control { + RememberMe +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + Typed(Email, email) -> Model(..model, email: email) + Typed(Password, password) -> Model(..model, password: password) + Toggled(RememberMe, remember_me) -> Model(..model, remember_me: remember_me) + } +} + +// RENDER ---------------------------------------------------------------------- + +fn render(model: Model) -> Element(Msg) { + div( + [attribute.class("container")], + [ + card([ + email_input(model.email), + password_input(model.password), + remember_checkbox(model.remember_me), + pre( + [attribute.class("debug")], + [ + string.inspect(model) + |> string.replace("(", "(\n ") + |> string.replace(", ", ",\n ") + |> string.replace(")", "\n)") + |> text, + ], + ), + ]), + ], + ) +} + +fn card(content: List(Element(a))) -> Element(a) { + div([attribute.class("card")], [div([], content)]) +} + +fn email_input(value: String) -> Element(Msg) { + render_input(Email, "email", "email-input", value, "Email address") +} + +fn password_input(value: String) -> Element(Msg) { + render_input(Password, "password", "password-input", value, "Password") +} + +fn render_input( + field: Input, + type_: String, + id: String, + value: String, + label_: String, +) -> Element(Msg) { + div( + [attribute.class("input")], + [ + label([attribute.for(id)], [text(label_)]), + input([ + attribute.id(id), + attribute.name(id), + attribute.type_(type_), + attribute.required(True), + attribute.value(dynamic.from(value)), + event.on_input(fn(value) { Typed(field, value) }), + ]), + ], + ) +} + +fn remember_checkbox(checked: Bool) -> Element(Msg) { + div( + [attribute.class("flex items-center")], + [ + input([ + attribute.id("remember-me"), + attribute.name("remember-me"), + attribute.type_("checkbox"), + attribute.checked(checked), + attribute.class("checkbox"), + event.on_click(Toggled(RememberMe, !checked)), + ]), + label([attribute.for("remember-me")], [text("Remember me")]), + ], + ) +} diff --git a/lib/test/examples/input.html b/lib/test/examples/input.html new file mode 100644 index 0000000..3bd6463 --- /dev/null +++ b/lib/test/examples/input.html @@ -0,0 +1,54 @@ + + + + + + lustre | forms + + + + + + + +
    + + diff --git a/lib/test/examples/nested.gleam b/lib/test/examples/nested.gleam new file mode 100644 index 0000000..47bb9d5 --- /dev/null +++ b/lib/test/examples/nested.gleam @@ -0,0 +1,57 @@ +// IMPORTS --------------------------------------------------------------------- + +import examples/counter +import gleam/list +import gleam/map.{Map} +import gleam/pair +import lustre +import lustre/element.{Element} +import lustre/element/html.{div} + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + Map(Int, counter.Model) + +fn init() -> Model { + use counters, id <- list.fold(list.range(1, 10), map.new()) + + map.insert(counters, id, counter.init()) +} + +// UPDATE ---------------------------------------------------------------------- + +type Msg = + #(Int, counter.Msg) + +fn update(model: Model, msg: Msg) -> Model { + let #(id, counter_msg) = msg + let assert Ok(counter) = map.get(model, id) + + map.insert(model, id, counter.update(counter, counter_msg)) +} + +// RENDER ---------------------------------------------------------------------- + +fn render(model: Model) -> Element(Msg) { + let counters = { + use rest, id, counter <- map.fold(model, []) + let el = element.map(counter.render(counter), pair.new(id, _)) + + [el, ..rest] + } + + div([], counters) +} diff --git a/lib/test/examples/nested.html b/lib/test/examples/nested.html new file mode 100644 index 0000000..420b159 --- /dev/null +++ b/lib/test/examples/nested.html @@ -0,0 +1,17 @@ + + + + + + lustre | nested + + + + +
    + + diff --git a/lib/test/examples/svg.gleam b/lib/test/examples/svg.gleam new file mode 100644 index 0000000..c1cc5fb --- /dev/null +++ b/lib/test/examples/svg.gleam @@ -0,0 +1,107 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre +import lustre/attribute.{attribute} +import lustre/element.{Element, text} +import lustre/element/html.{button, div, p, svg} +import lustre/element/svg.{path} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") +} + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init() -> Model { + 0 +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr + Reset +} + +pub fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + Reset -> 0 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn render(model: Model) -> Element(Msg) { + div( + [], + [ + button( + [event.on_click(Incr)], + [plus([attribute.style([#("color", "red")])])], + ), + button([event.on_click(Decr)], [minus([])]), + button([event.on_click(Reset)], [text("Reset")]), + p([], [text(int.to_string(model))]), + ], + ) +} + +fn plus(attrs) { + svg( + [ + attribute("width", "15"), + attribute("height", "15"), + attribute("viewBox", "0 0 15 15"), + attribute("fill", "none"), + ..attrs + ], + [ + path([ + attribute( + "d", + "M8 2.75C8 2.47386 7.77614 2.25 7.5 2.25C7.22386 2.25 7 2.47386 7 2.75V7H2.75C2.47386 7 2.25 7.22386 2.25 7.5C2.25 7.77614 2.47386 8 2.75 8H7V12.25C7 12.5261 7.22386 12.75 7.5 12.75C7.77614 12.75 8 12.5261 8 12.25V8H12.25C12.5261 8 12.75 7.77614 12.75 7.5C12.75 7.22386 12.5261 7 12.25 7H8V2.75Z", + ), + attribute("fill", "currentColor"), + attribute("fill-rule", "evenodd"), + attribute("clip-rule", "evenodd"), + ]), + ], + ) +} + +fn minus(attrs) { + svg( + [ + attribute("width", "15"), + attribute("height", "15"), + attribute("viewBox", "0 0 15 15"), + attribute("fill", "none"), + ..attrs + ], + [ + path([ + attribute( + "d", + "M2.25 7.5C2.25 7.22386 2.47386 7 2.75 7H12.25C12.5261 7 12.75 7.22386 12.75 7.5C12.75 7.77614 12.5261 8 12.25 8H2.75C2.47386 8 2.25 7.77614 2.25 7.5Z", + ), + attribute("fill", "currentColor"), + attribute("fill-rule", "evenodd"), + attribute("clip-rule", "evenodd"), + ]), + ], + ) +} diff --git a/lib/test/examples/svg.html b/lib/test/examples/svg.html new file mode 100644 index 0000000..12af526 --- /dev/null +++ b/lib/test/examples/svg.html @@ -0,0 +1,17 @@ + + + + + + lustre | svg + + + + +
    + + diff --git a/manifest.toml b/manifest.toml deleted file mode 100644 index a5dc3b1..0000000 --- a/manifest.toml +++ /dev/null @@ -1,11 +0,0 @@ -# This file was generated by Gleam -# You typically do not need to edit this file - -packages = [ - { name = "funtil", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "funtil", source = "hex", outer_checksum = "408E301240E6039FA0D9AB24E648FC176DFB82486835AFE23FE59B40222CEC9A" }, - { name = "gleam_stdlib", version = "0.30.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "03710B3DA047A3683117591707FCA19D32B980229DD8CE8B0603EB5B5144F6C3" }, -] - -[requirements] -funtil = { version = "~> 1.0" } -gleam_stdlib = { version = "~> 0.30" } diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 7fafc3d..0000000 --- a/package-lock.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "name": "lustre-core", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "devDependencies": { - "vite": "^4.4.2" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.11", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild": { - "version": "0.18.11", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.11", - "@esbuild/android-arm64": "0.18.11", - "@esbuild/android-x64": "0.18.11", - "@esbuild/darwin-arm64": "0.18.11", - "@esbuild/darwin-x64": "0.18.11", - "@esbuild/freebsd-arm64": "0.18.11", - "@esbuild/freebsd-x64": "0.18.11", - "@esbuild/linux-arm": "0.18.11", - "@esbuild/linux-arm64": "0.18.11", - "@esbuild/linux-ia32": "0.18.11", - "@esbuild/linux-loong64": "0.18.11", - "@esbuild/linux-mips64el": "0.18.11", - "@esbuild/linux-ppc64": "0.18.11", - "@esbuild/linux-riscv64": "0.18.11", - "@esbuild/linux-s390x": "0.18.11", - "@esbuild/linux-x64": "0.18.11", - "@esbuild/netbsd-x64": "0.18.11", - "@esbuild/openbsd-x64": "0.18.11", - "@esbuild/sunos-x64": "0.18.11", - "@esbuild/win32-arm64": "0.18.11", - "@esbuild/win32-ia32": "0.18.11", - "@esbuild/win32-x64": "0.18.11" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.6", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.4.25", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "3.26.2", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/vite": { - "version": "4.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.24", - "rollup": "^3.25.2" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index fdc091a..0000000 --- a/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "private": true, - "type": "module", - "scripts": { - "dev": "gleam build && vite serve ./test/examples" - }, - "devDependencies": { - "vite": "^4.4.2" - } -} diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs deleted file mode 100644 index b99f6e2..0000000 --- a/src/lustre.ffi.mjs +++ /dev/null @@ -1,206 +0,0 @@ -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/src/lustre.gleam b/src/lustre.gleam deleted file mode 100644 index 673f982..0000000 --- a/src/lustre.gleam +++ /dev/null @@ -1,254 +0,0 @@ -//// 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/src/lustre/attribute.gleam b/src/lustre/attribute.gleam deleted file mode 100644 index 459a86e..0000000 --- a/src/lustre/attribute.gleam +++ /dev/null @@ -1,408 +0,0 @@ -// 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/src/lustre/effect.gleam b/src/lustre/effect.gleam deleted file mode 100644 index 19f54b0..0000000 --- a/src/lustre/effect.gleam +++ /dev/null @@ -1,67 +0,0 @@ -// 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/src/lustre/element.gleam b/src/lustre/element.gleam deleted file mode 100644 index 4e8abee..0000000 --- a/src/lustre/element.gleam +++ /dev/null @@ -1,126 +0,0 @@ -// 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/src/lustre/element/html.gleam b/src/lustre/element/html.gleam deleted file mode 100644 index 9eb4f5e..0000000 --- a/src/lustre/element/html.gleam +++ /dev/null @@ -1,1197 +0,0 @@ -// 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 , , - - -
    - - diff --git a/test/examples/counter.gleam b/test/examples/counter.gleam deleted file mode 100644 index 759ebdf..0000000 --- a/test/examples/counter.gleam +++ /dev/null @@ -1,56 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import gleam/int -import lustre -import lustre/element.{Element, text} -import lustre/element/html.{button, div, p} -import lustre/event - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - // A `simple` lustre application doesn't produce `Effect`s. These are best to - // start with if you're just getting started with lustre or you know you don't - // need the runtime to manage any side effects. - let app = lustre.simple(init, update, render) - let assert Ok(_) = lustre.start(app, "[data-lustre-app]") -} - -// MODEL ----------------------------------------------------------------------- - -pub type Model = - Int - -pub fn init() -> Model { - 0 -} - -// UPDATE ---------------------------------------------------------------------- - -pub opaque type Msg { - Incr - Decr - Reset -} - -pub fn update(model: Model, msg: Msg) -> Model { - case msg { - Incr -> model + 1 - Decr -> model - 1 - Reset -> 0 - } -} - -// VIEW ------------------------------------------------------------------------ - -pub fn render(model: Model) -> Element(Msg) { - div( - [], - [ - button([event.on_click(Incr)], [text("+")]), - button([event.on_click(Decr)], [text("-")]), - button([event.on_click(Reset)], [text("Reset")]), - p([], [text(int.to_string(model))]), - ], - ) -} diff --git a/test/examples/counter.html b/test/examples/counter.html deleted file mode 100644 index 2b120fd..0000000 --- a/test/examples/counter.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - lustre | counter - - - - -
    - - diff --git a/test/examples/index.html b/test/examples/index.html deleted file mode 100644 index 70d4196..0000000 --- a/test/examples/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - lustre | examples - - - -
  • - input -
  • -
  • - counter -
  • -
  • - nested -
  • -
  • - svg -
  • -
  • - components -
  • -
    - - diff --git a/test/examples/input.gleam b/test/examples/input.gleam deleted file mode 100644 index d59c0c9..0000000 --- a/test/examples/input.gleam +++ /dev/null @@ -1,132 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import gleam/dynamic -import gleam/string -import lustre -import lustre/attribute.{attribute} -import lustre/element.{Element, text} -import lustre/element/html.{div, input, label, pre} -import lustre/event - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - // A `simple` lustre application doesn't produce `Effect`s. These are best to - // start with if you're just getting started with lustre or you know you don't - // need the runtime to manage any side effects. - let app = lustre.simple(init, update, render) - let assert Ok(_) = lustre.start(app, "[data-lustre-app]") - - Nil -} - -// MODEL ----------------------------------------------------------------------- - -type Model { - Model(email: String, password: String, remember_me: Bool) -} - -fn init() -> Model { - Model(email: "", password: "", remember_me: False) -} - -// UPDATE ---------------------------------------------------------------------- - -type Msg { - Typed(Input, String) - Toggled(Control, Bool) -} - -type Input { - Email - Password -} - -type Control { - RememberMe -} - -fn update(model: Model, msg: Msg) -> Model { - case msg { - Typed(Email, email) -> Model(..model, email: email) - Typed(Password, password) -> Model(..model, password: password) - Toggled(RememberMe, remember_me) -> Model(..model, remember_me: remember_me) - } -} - -// RENDER ---------------------------------------------------------------------- - -fn render(model: Model) -> Element(Msg) { - div( - [attribute.class("container")], - [ - card([ - email_input(model.email), - password_input(model.password), - remember_checkbox(model.remember_me), - pre( - [attribute.class("debug")], - [ - string.inspect(model) - |> string.replace("(", "(\n ") - |> string.replace(", ", ",\n ") - |> string.replace(")", "\n)") - |> text, - ], - ), - ]), - ], - ) -} - -fn card(content: List(Element(a))) -> Element(a) { - div([attribute.class("card")], [div([], content)]) -} - -fn email_input(value: String) -> Element(Msg) { - render_input(Email, "email", "email-input", value, "Email address") -} - -fn password_input(value: String) -> Element(Msg) { - render_input(Password, "password", "password-input", value, "Password") -} - -fn render_input( - field: Input, - type_: String, - id: String, - value: String, - label_: String, -) -> Element(Msg) { - div( - [attribute.class("input")], - [ - label([attribute.for(id)], [text(label_)]), - input([ - attribute.id(id), - attribute.name(id), - attribute.type_(type_), - attribute.required(True), - attribute.value(dynamic.from(value)), - event.on_input(fn(value) { Typed(field, value) }), - ]), - ], - ) -} - -fn remember_checkbox(checked: Bool) -> Element(Msg) { - div( - [attribute.class("flex items-center")], - [ - input([ - attribute.id("remember-me"), - attribute.name("remember-me"), - attribute.type_("checkbox"), - attribute.checked(checked), - attribute.class("checkbox"), - event.on_click(Toggled(RememberMe, !checked)), - ]), - label([attribute.for("remember-me")], [text("Remember me")]), - ], - ) -} diff --git a/test/examples/input.html b/test/examples/input.html deleted file mode 100644 index 3bd6463..0000000 --- a/test/examples/input.html +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - lustre | forms - - - - - - - -
    - - diff --git a/test/examples/nested.gleam b/test/examples/nested.gleam deleted file mode 100644 index 47bb9d5..0000000 --- a/test/examples/nested.gleam +++ /dev/null @@ -1,57 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import examples/counter -import gleam/list -import gleam/map.{Map} -import gleam/pair -import lustre -import lustre/element.{Element} -import lustre/element/html.{div} - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - // A `simple` lustre application doesn't produce `Effect`s. These are best to - // start with if you're just getting started with lustre or you know you don't - // need the runtime to manage any side effects. - let app = lustre.simple(init, update, render) - let assert Ok(_) = lustre.start(app, "[data-lustre-app]") - - Nil -} - -// MODEL ----------------------------------------------------------------------- - -type Model = - Map(Int, counter.Model) - -fn init() -> Model { - use counters, id <- list.fold(list.range(1, 10), map.new()) - - map.insert(counters, id, counter.init()) -} - -// UPDATE ---------------------------------------------------------------------- - -type Msg = - #(Int, counter.Msg) - -fn update(model: Model, msg: Msg) -> Model { - let #(id, counter_msg) = msg - let assert Ok(counter) = map.get(model, id) - - map.insert(model, id, counter.update(counter, counter_msg)) -} - -// RENDER ---------------------------------------------------------------------- - -fn render(model: Model) -> Element(Msg) { - let counters = { - use rest, id, counter <- map.fold(model, []) - let el = element.map(counter.render(counter), pair.new(id, _)) - - [el, ..rest] - } - - div([], counters) -} diff --git a/test/examples/nested.html b/test/examples/nested.html deleted file mode 100644 index 420b159..0000000 --- a/test/examples/nested.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - lustre | nested - - - - -
    - - diff --git a/test/examples/svg.gleam b/test/examples/svg.gleam deleted file mode 100644 index c1cc5fb..0000000 --- a/test/examples/svg.gleam +++ /dev/null @@ -1,107 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import gleam/int -import lustre -import lustre/attribute.{attribute} -import lustre/element.{Element, text} -import lustre/element/html.{button, div, p, svg} -import lustre/element/svg.{path} -import lustre/event - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - // A `simple` lustre application doesn't produce `Effect`s. These are best to - // start with if you're just getting started with lustre or you know you don't - // need the runtime to manage any side effects. - let app = lustre.simple(init, update, render) - let assert Ok(_) = lustre.start(app, "[data-lustre-app]") -} - -// MODEL ----------------------------------------------------------------------- - -pub type Model = - Int - -pub fn init() -> Model { - 0 -} - -// UPDATE ---------------------------------------------------------------------- - -pub opaque type Msg { - Incr - Decr - Reset -} - -pub fn update(model: Model, msg: Msg) -> Model { - case msg { - Incr -> model + 1 - Decr -> model - 1 - Reset -> 0 - } -} - -// VIEW ------------------------------------------------------------------------ - -pub fn render(model: Model) -> Element(Msg) { - div( - [], - [ - button( - [event.on_click(Incr)], - [plus([attribute.style([#("color", "red")])])], - ), - button([event.on_click(Decr)], [minus([])]), - button([event.on_click(Reset)], [text("Reset")]), - p([], [text(int.to_string(model))]), - ], - ) -} - -fn plus(attrs) { - svg( - [ - attribute("width", "15"), - attribute("height", "15"), - attribute("viewBox", "0 0 15 15"), - attribute("fill", "none"), - ..attrs - ], - [ - path([ - attribute( - "d", - "M8 2.75C8 2.47386 7.77614 2.25 7.5 2.25C7.22386 2.25 7 2.47386 7 2.75V7H2.75C2.47386 7 2.25 7.22386 2.25 7.5C2.25 7.77614 2.47386 8 2.75 8H7V12.25C7 12.5261 7.22386 12.75 7.5 12.75C7.77614 12.75 8 12.5261 8 12.25V8H12.25C12.5261 8 12.75 7.77614 12.75 7.5C12.75 7.22386 12.5261 7 12.25 7H8V2.75Z", - ), - attribute("fill", "currentColor"), - attribute("fill-rule", "evenodd"), - attribute("clip-rule", "evenodd"), - ]), - ], - ) -} - -fn minus(attrs) { - svg( - [ - attribute("width", "15"), - attribute("height", "15"), - attribute("viewBox", "0 0 15 15"), - attribute("fill", "none"), - ..attrs - ], - [ - path([ - attribute( - "d", - "M2.25 7.5C2.25 7.22386 2.47386 7 2.75 7H12.25C12.5261 7 12.75 7.22386 12.75 7.5C12.75 7.77614 12.5261 8 12.25 8H2.75C2.47386 8 2.25 7.77614 2.25 7.5Z", - ), - attribute("fill", "currentColor"), - attribute("fill-rule", "evenodd"), - attribute("clip-rule", "evenodd"), - ]), - ], - ) -} diff --git a/test/examples/svg.html b/test/examples/svg.html deleted file mode 100644 index 12af526..0000000 --- a/test/examples/svg.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - lustre | svg - - - - -
    - - -- cgit v1.2.3