diff options
41 files changed, 1669 insertions, 962 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1af08c1..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: test - -on: - push: - branches: - - master - - main - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2.0.0 - - uses: erlef/setup-beam@v1.9.0 - with: - otp-version: "23.2" - gleam-version: "0.19.0" - # nice try louis - # - run: gleam format --check src test - - run: gleam deps download - - run: gleam test diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 0000000..57e4984 --- /dev/null +++ b/docs/404.html @@ -0,0 +1,56 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Lustre</title> + + <script type="module"> + // Single Page Apps for GitHub Pages + // MIT License + // https://github.com/rafgraph/spa-github-pages + // This script takes the current url and converts the path and query + // string into just a query string, and then redirects the browser + // to the new url with only a query string and hash fragment, + // e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes + // https://www.foo.tld/?/one/two&a=b~and~c=d#qwe + // Note: this 404.html file must be at least 512 bytes for it to work + // with Internet Explorer (it is currently > 512 bytes) + + // If you're creating a Project Pages site and NOT using a custom domain, + // then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1). + // This way the code will only replace the route part of the path, and not + // the real directory in which the app resides, for example: + // https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes + // https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe + // Otherwise, leave pathSegmentsToKeep as 0. + const pathSegmentsToKeep = import.meta.env.BASE_URL === "/" ? 0 : 1; + + if (window.location.pathname.startsWith("/lustre")) { + const protocol = window.location.protocol; + const hostname = window.location.hostname; + const port = window.location.port; + const pathname = window.location.pathname + .split("/") + .slice(0, 1 + pathSegmentsToKeep) + .join("/"); + const query = window.location.pathname + .slice(1) + .split("/") + .slice(pathSegmentsToKeep) + .join("/") + .replace(/&/g, "~and~"); + const search = window.location.search + ? "&" + window.location.search.slice(1).replace(/&/g, "~and~") + : ""; + const hash = window.location.hash; + + window.location.replace( + `${protocol}//${hostname}${ + port ? ":" + port : "" + }${pathname}/?/${query}${search}${hash}` + ); + } + </script> + </head> + <body></body> +</html> diff --git a/docs/assets/styles.css b/docs/assets/styles.css new file mode 100644 index 0000000..308e6e8 --- /dev/null +++ b/docs/assets/styles.css @@ -0,0 +1,16 @@ +@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400&family=Inter:wght@300&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-serif; + } +} diff --git a/docs/index.html b/docs/index.html index ba9941e..0e7b438 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,50 +5,49 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Lustre</title> - <script type="module"> - document.head.appendChild( - Object.assign(document.createElement("base"), { - href: import.meta.env.BASE_URL, - }) - ); - </script> + <link rel="stylesheet" href="./assets/styles.css" /> - <style> - @font-face { - font-family: "NTDapper"; - font-style: normal; - font-weight: 400; - src: url("/fonts/NTDapper-regular.woff2") format("woff2"); - } - @font-face { - font-family: "NTDapper"; - font-style: normal; - font-weight: 500; - src: url("/fonts/NTDapper-medium.woff2") format("woff2"); - } - @font-face { - font-family: "NTDapper"; - font-style: normal; - font-weight: 700; - src: url("/fonts/NTDapper-bold.woff2") format("woff2"); - } - @font-face { - font-family: "NTDapper"; - font-style: normal; - font-weight: 900; - src: url("/fonts/NTDapper-black.woff2") format("woff2"); + <!-- Start Single Page Apps for GitHub Pages --> + <script type="text/javascript"> + // Single Page Apps for GitHub Pages + // MIT License + // https://github.com/rafgraph/spa-github-pages + // This script checks to see if a redirect is present in the query string, + // converts it back into the correct url and adds it to the + // browser's history using window.history.replaceState(...), + // which won't cause the browser to attempt to load the new url. + // When the single page app is loaded further down in this file, + // the correct url will be waiting in the browser's history for + // the single page app to route accordingly. + if (window.location.search[1] === "/") { + const decoded = window.location.search + .slice(1) + .split("&") + .map((s) => s.replace(/~and~/g, "&")) + .join("?"); + + window.history.replaceState( + null, + null, + window.location.pathname.slice(0, -1) + decoded + window.location.hash + ); } + </script> + <!-- End Single Page Apps for GitHub Pages --> - @tailwind base; - @tailwind components; - @tailwind utilities; - </style> <script type="module"> - import { main, OnRouteChange } from "./src/app.gleam"; + import { main, Route, OnRouteChange } from "./src/app.gleam"; + + const strip_base = (path) => + `/${path.slice(import.meta.env.BASE_URL.length)}`; document.addEventListener("DOMContentLoaded", () => { const url = new URL(window.location.href); - const dispatch = main({ path: url.pathname, hash: url.hash }); + + const dispatch = main({ + path: strip_base(url.pathname), + hash: url.hash, + }); // We want to trap click events on anchor elements so we can do our own // client side routing. @@ -59,17 +58,21 @@ if (target === document.body) return; if (target.tagName === "A") { const url = new URL(target.href); - if (url.origin !== window.location.origin) return; - if (url.pathname !== window.location.pathname) e.preventDefault(); + const route = new Route(strip_base(url.pathname), url.hash); + + e.preventDefault(); + window.requestAnimationFrame(() => { + window.history.pushState({}, "", url.href); - window.requestAnimationFrame(() => - window.history.pushState({}, "", url.href) - ); + if (url.pathname === window.location.pathname && url.hash) { + document.querySelector(url.hash)?.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + }); - return void dispatch( - new OnRouteChange({ path: url.pathname, hash: url.hash }) - ); + return void dispatch(new OnRouteChange(route)); } target = target.parentNode; @@ -80,8 +83,9 @@ // and trigger our app's routing. window.addEventListener("popstate", () => { const url = new URL(window.location.href); + const route = new Route(strip_base(url.pathname), url.hash); - dispatch(new OnRouteChange({ path: url.pathname, hash: url.hash })); + dispatch(new OnRouteChange(route)); }); }); </script> diff --git a/docs/package-lock.json b/docs/package-lock.json index 36008e3..dabf565 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "highlight.js": "^11.8.0", "mdast-util-from-markdown": "^2.0.0" }, "devDependencies": { @@ -13,7 +14,7 @@ "postcss": "^8.4.28", "tailwindcss": "^3.3.3", "vite": "^4.4.9", - "vite-gleam": "^0.2.3", + "vite-gleam": "^0.2.4", "vite-plugin-gh-pages": "^0.4.1" } }, @@ -1168,6 +1169,14 @@ "node": ">= 0.4.0" } }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2547,9 +2556,9 @@ } }, "node_modules/vite-gleam": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/vite-gleam/-/vite-gleam-0.2.3.tgz", - "integrity": "sha512-wENY/Ezk4mYiwl+wnsYQUH4KPQUQ3iMvmttwNUfAZVbDMrsVv0uvJ5N1S2fnAG+O9VIabI6sl9pxJksAowc6WA==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/vite-gleam/-/vite-gleam-0.2.4.tgz", + "integrity": "sha512-eZ6raXxbdbGkTwH4vABcgklbMP1hdQDL2B/uYhj9fLZquqBAQKBe//2nWBPwBTdOtSc7ABEG6VUctaUwuN+zUw==", "dev": true, "dependencies": { "magic-string": "^0.30.2", diff --git a/docs/package.json b/docs/package.json index e6b636e..3b74037 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,10 +9,11 @@ "postcss": "^8.4.28", "tailwindcss": "^3.3.3", "vite": "^4.4.9", - "vite-gleam": "^0.2.3", + "vite-gleam": "^0.2.4", "vite-plugin-gh-pages": "^0.4.1" }, "dependencies": { + "highlight.js": "^11.8.0", "mdast-util-from-markdown": "^2.0.0" } } diff --git a/docs/public/CNAME b/docs/public/CNAME new file mode 100644 index 0000000..b9c16b8 --- /dev/null +++ b/docs/public/CNAME @@ -0,0 +1 @@ +pkg.hayleigh.dev
\ No newline at end of file diff --git a/docs/public/page/api/lustre.md b/docs/public/page/api/lustre.md new file mode 100644 index 0000000..7ec5021 --- /dev/null +++ b/docs/public/page/api/lustre.md @@ -0,0 +1,195 @@ +# lustre + +## Applications + +### App | erlang javascript + +```gleam +pub type App(flags, model, msg) +``` + +The `App` type represents all the parts that make up a Lustre program in the +Model-View-Update architecture along with the runtime necessary to run it. + +Although the type itself is exposed to both the Erlang and JavaScript targets, +the functions in this module to construct an `App` are only available in the +JavaScript target, and `start` will only succeed when ran in the browser. + +In the future we may have a way to run Lustre applications on the backend, if +you have any ideas on how to achieve this I'd love to hear about them! + +### Error | erlang javascript + +```gleam +pub type Error { + AppAlreadyStarted + AppNotYetStarted + BadComponentName + ComponentAlreadyRegistered + ElementNotFound + NotABrowser +} +``` + +The `Error` type represents all the ways that a Lustre program can fail. These +include things like trying to start an application that has already been started, +registering a component with a name that is not valid, or trying to start an +application in a context that is not a browser. + +Often you will want to perform a couple of these actions together, and unifying +the error type makes this easy. In many of the examples we `let assert` that the +result is `Ok` but if you wanted to be a bit more dilligent you might use +`result.try` instead: + +```gleam +import gleam/result +import lustre + +pub fn main () { + use _ <- result.try(lustre.component("my-component", ...)) + let app = lustre.application(...) + use dispatch <- result.try(lustre.start(app, "[data-lustre-app]", Nil)) + + ... +} +``` + +### element | javascript + +```gleam +pub fn element(el: Element(msg)) -> App(Nil, Nil, msg) +``` + +An `element` application is the simplest kind of Lustre program. It takes an +`Element` to render and renders it to the DOM. These applications hold no state +and do not respond to messages, but that doesn't mean they are not interactive! + +It is possible for [`components`](#component) to be rendered inside an +`element` application, and these components can be interactive with their own +contained state and update loops. + +### simple | javascript + +```gleam +pub fn simple( + init: fn(flags) -> model, + update: fn(model, msg) -> model, + view: fn(model) -> Element(msg) +) -> App(flags, model, msg) +``` + +A `simple` program introduces the Model-View-Update architecture but leaves out +the ability to dispatch side effects. This means your programs are interactive +but cannot talk to the outside world. + +### application | javascript + +```gleam +pub fn application( + init: fn(flags) -> #(model, Effect(msg)), + update: fn(model, msg) -> #(model, Effect(msg)), + view: fn(model) -> Element(msg) +) -> App(flags, model, msg) +``` + +The `application` constructor is the most complete way to build a Lustre app. As +with [`simple`](#simple) it uses the Model-View-Update architecture, but now your +init and update functions can return side effects to be performed by the runtime +in the form of an [`Effect`](/api/lustre/effect#effect-type). + +### start | javascript + +```gleam +pub fn start( + app: App(flags, model, msg), + selector: String, + flags: flags, +) -> Result(fn(msg) -> Nil, Error) +``` + +Start an application by providing a CSS selector to find the element to mount the +application onto and any flags to pass to the application on first init. This +function returns a `Result` and may fail for a number of reasons. Check out the +[`Error`](#error-type) type for more information. + +### destroy | javascript + +```gleam +pub fn destroy(app: App(flags, model, msg)) -> Result(Nil, Error) +``` + +Tear down a running application and remove it from the DOM. This can fail if the +application has not yet been started. + +## Components + +Components take the same Model-View-Update building blocks used to create Lustre +applications and allow them to be used as reusable stateful components. This is +slightly different to how components are used in other frameworks like React +where "component" refers more generally to any reusable piece of UI. + +In Lustre, functions that return an `Element` are known as "view functions" and +components are more specific abstractions that encapsulate state and behaviour +you might not want to deal with in your top-level application. + +Resist the urge to reach for components too early. The Elm community has managed +to make do without components at all: you can get surprisingly far storing state +in your top level application and passing it down to different view functions. +This comes with the added benefit of it being much easier to reason about your +UI as a whole. + +### component | javascript + +```gleam +pub fn component( + name: String, + init: fn() -> #(model, Effect(msg)), + update: fn(model, msg) -> #(model, Effect(msg)), + view: fn(model) -> Element(msg), + on_attribute_change: Map(String, Decoder(msg)), +) -> Result(Nil, Error) +``` + +Register a component with the runtime from the familiar Model-View-Update building +blocks. Compared to an application, we have two additional arguments: + +- A name for the component. This name must follow the same rules laid out in the + [custom element spec](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name) + and should contain a hyphen (`-`) to avoid clashes with built-in HTML elements. +- A map of attribute names to listen for changes to and a decoder for each to + decode those attributes into messages to send to your component's `update` + function. + +If it feels like the API for registering components is a little more verbose than +you're used to, that's because it is! You can get surprisingly far storing state +in your top level application and passing it down to different view functions +without needing to use components at all. In fact, for communities like Elm this +is the _only_ way to do things. + +## Utilities + +### is_browser | erlang javascript + +```gleam +pub fn is_browser() -> Bool +``` + +Gleam has conditional compilation depending on whether you are targetting Erlang +or JavaScript, but sometimes you want to be a bit more specific than that and +check if you're running in the browser. + +This is a runtime check that will tell you just that. You could use this to create +a view function that renders something simple on the backend but more complex or +interactive on the frontend. + +### is_registered | erlang javascript + +```gleam +pub fn is_registered(name: String) -> Bool +``` + +Lustre's components are built directly on the +[custom element spec](https://html.spec.whatwg.org/multipage/custom-elements.html) +which means they share the same global registery as other custom elements. This +function can tell you if the name you want to use is already registered, by another +Lustre component or otherwise. diff --git a/docs/src/app/page/api/lustre/attribute.gleam b/docs/public/page/api/lustre/attribute.md index 4578e46..b23000b 100644 --- a/docs/src/app/page/api/lustre/attribute.gleam +++ b/docs/public/page/api/lustre/attribute.md @@ -1,39 +1,5 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [ - title, - constructing_attributes, - mapping_attributes, - conversions, - common_attributes, - input_attributes, - more_input_attributes, - range_attributes, - textarea_attributes, - link_attributes, - embedded_content, - audio_and_video, - ] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " # lustre/attribute -" -// CONTENT: CONSTRUCTING ATTRIBUTES -------------------------------------------- - -const constructing_attributes: String = " ## Constructing attributes ### Attribute | erlang javascript @@ -57,13 +23,12 @@ pub fn property(name: String, value: any) -> Attribute(msg) ### on | erlang javascript ```gleam -pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) +pub fn on( + name: String, + handler: fn(Dynamic) -> Result(msg, error) +) -> Attribute(msg) ``` -" - -// CONTENT: MAPPING ATTRIBUTES ------------------------------------------------- -const mapping_attributes: String = " ## Mapping attributes ### map | erlang javascript @@ -71,11 +36,7 @@ const mapping_attributes: String = " ```gleam pub fn map(attr: Attribute(a), f: fn(a) -> b) -> Attribute(b) ``` -" -// CONTENT: CONVERSIONS -------------------------------------------------------- - -const conversions: String = " ## Conversions ### to_string | erlang javascript @@ -89,11 +50,7 @@ pub fn to_string(attr: Attribute(msg)) -> String ```gleam pub fn to_string_builder(attr: Attribute(msg)) -> StringBuilder ``` -" - -// CONTENT: COMMON ATTRIBUTES -------------------------------------------------- -const common_attributes: String = " ## Common attributes ### style | erlang javascript @@ -111,7 +68,7 @@ pub fn class(name: String) -> Attribute(msg) ### classes | erlang javascript ```gleam -pub fn classes(names: List(#(String, Bool))) -> Attribute(msg) +pub fn classes(names: List(#(String, Bool))) -> Attribute(msg) ``` ### id | erlang javascript @@ -119,14 +76,10 @@ pub fn classes(names: List(#(String, Bool))) -> Attribute(msg) ```gleam pub fn id(name: String) -> Attribute(msg) ``` -" -// CONTENT: INPUT ATTRIBUTES --------------------------------------------------- - -const input_attributes: String = " ## Input attributes -### type_ | erlang javascript +### type\_ | erlang javascript ```gleam pub fn type_(name: String) -> Attribute(msg) @@ -155,11 +108,7 @@ pub fn placeholder(text: String) -> Attribute(msg) ```gleam pub fn selected(is_selected: Bool) -> Attribute(msg ``` -" - -// CONTENT: MORE INPUT ATTRIBUTES ---------------------------------------------- -const more_input_attributes: String = " ## More input attributes ### accept | erlang javascript @@ -227,11 +176,7 @@ pub fn required(is_required: Bool) -> Attribute(msg) ```gleam pub fn for(id: String) -> Attribute(msg) ``` -" - -// CONTENT: RANGE ATTRIBUTES --------------------------------------------------- -const range_attributes: String = " ## Range attributes ### max | erlang javascript @@ -251,11 +196,7 @@ pub fn min(val: String) -> Attribute(msg) ```gleam pub fn step(val: String) -> Attribute(msg) ``` -" -// CONTENT: TEXTAREA ATTRIBUTES ------------------------------------------------ - -const textarea_attributes: String = " ## Textarea attributes ### cols | erlang javascript @@ -275,11 +216,7 @@ pub fn rows(val: Int) -> Attribute(msg) ```gleam pub fn wrap(mode: String) -> Attribute(msg) ``` -" - -// CONTENT: LINK ATTRIBUTES ---------------------------------------------------- -const link_attributes: String = " ## Link attributes ### href | erlang javascript @@ -305,11 +242,7 @@ pub fn download(filename: String) -> Attribute(msg) ```gleam pub fn rel(relationship: String) -> Attribute(msg) ``` -" -// CONTENT: EMBEDDED CONTENT --------------------------------------------------- - -const embedded_content: String = " ## Embedded content ### gleam | erlang javascript @@ -335,31 +268,23 @@ pub fn width(val: Int) -> Attribute(msg) ```gleam pub fn alt(text: String) -> Attribute(msg) ``` -" - -// CONTENT: AUDIO AND VIDEO ATTRIBUTES ----------------------------------------- -const audio_and_video: String = " ## Audio and video attributes - ### autoplay | erlang javascript ```gleam pub fn autoplay(should_autoplay: Bool) -> Attribute(msg) ``` - ### controls | erlang javascript ```gleam pub fn controls(visible: Bool) -> Attribute(msg) ``` - ### loop | erlang javascript ```gleam pub fn loop(should_loop: Bool) -> Attribute(msg) ``` -" diff --git a/docs/public/page/api/lustre/effect.md b/docs/public/page/api/lustre/effect.md new file mode 100644 index 0000000..3a0bf11 --- /dev/null +++ b/docs/public/page/api/lustre/effect.md @@ -0,0 +1,35 @@ +# lustre/effect + +## Constructing Effects + +### Effect | erlang javascript + +```gleam +pub opaque type Effect(msg) +``` + +### from | erlang javascript + +```gleam +pub fn from(effect: fn(fn(msg) -> Nil) -> Nil) -> Effect(msg) +``` + +### none | erlang javascript + +```gleam +pub fn none() -> Effect(msg) +``` + +### batch | erlang javascript + +```gleam +pub fn batch(effects: List(Effect(msg))) -> Effect(msg) +``` + +## Manipulating Effects + +### map | erlang javascript + +```gleam +pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) +``` diff --git a/docs/public/page/api/lustre/element.md b/docs/public/page/api/lustre/element.md new file mode 100644 index 0000000..3510acd --- /dev/null +++ b/docs/public/page/api/lustre/element.md @@ -0,0 +1,58 @@ +# lustre/element + +## Constructing elements + +### Element | erlang javascript + +```gleam +pub opaque type Element(msg) +``` + +### element | erlang javascript + +```gleam +pub fn element( + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) +``` + +### namespaced | erlang javascript + +```gleam +pub fn namespaced( + namespace: String, + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) +``` + +### text | erlang javascript + +```gleam +pub fn text(content: String) -> Element(msg) +``` + +## Mapping elements + +### map | erlang javascript + +```gleam +pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) +``` + +## Conversions + +### to_string | erlang javascript + +```gleam +pub fn to_string(element: Element(msg)) -> String +``` + +### to_string_builder | erlang javascript + +```gleam +pub fn to_string_builder(element: Element(msg)) -> StringBuilder +``` diff --git a/docs/src/app/page/api/lustre/element/html.gleam b/docs/public/page/api/lustre/element/html.md index 6688195..883c66c 100644 --- a/docs/src/app/page/api/lustre/element/html.gleam +++ b/docs/public/page/api/lustre/element/html.md @@ -1,43 +1,5 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [ - title, - main_root, - document_metadata, - sectioning_root, - content_sectioning, - text_content, - inline_text_semantics, - image_and_multimedia, - embedded_content, - svg_and_mathml, - scripting, - demarcating_edits, - table_content, - forms, - interactive_elements, - web_components, - ] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " # lustre/element/html -" -// CONTENT: MAIN ROOT --------------------------------------------------------- - -const main_root: String = " ## Main Root ### html | erlang javascript @@ -48,11 +10,7 @@ pub fn html( children: List(Element(msg)), ) -> Element(msg) ``` -" - -// CONTENT: DOCUMENT METADATA -------------------------------------------------- -const document_metadata: String = " ## Document Metadata ### base | erlang javascript @@ -90,11 +48,7 @@ pub fn style(attrs: List(Attribute(msg)), css: String) -> Element(msg) ```gleam pub fn title(attrs: List(Attribute(msg)), content: String) -> Element(msg) ``` -" - -// CONTENT: SECTIONING ROOT ---------------------------------------------------- -const sectioning_root: String = " ## Sectioning root ### body | erlang javascript @@ -105,11 +59,7 @@ pub fn body( children: List(Element(msg)), ) -> Element(msg) ``` -" -// CONTENT: CONTENT SECTIONING ------------------------------------------------- - -const content_sectioning: String = " ## Content sectioning ### address | erlang javascript @@ -255,11 +205,7 @@ pub fn search( children: List(Element(msg)), ) -> Element(msg) ``` -" - -// CONTENT: TEXT CONTENT ------------------------------------------------------- -const text_content: String = " ## Text content ### blockquote | erlang javascript @@ -384,10 +330,7 @@ pub fn ul( children: List(Element(msg)), ) -> Element(msg) ``` -" -// CONTENT: INLINE TEXT SEMANTICS ---------------------------------------------- -const inline_text_semantics: String = " ## Inline text semantics ### a | erlang javascript @@ -644,10 +587,7 @@ pub fn var( ```gleam pub fn wbr(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: IMAGE AND MULTIMEDIA ----------------------------------------------- -const image_and_multimedia: String = " ## Image and multimedia ### area | erlang javascript @@ -694,11 +634,7 @@ pub fn video( children: List(Element(msg)), ) -> Element(msg) ``` -" - -// CONTENT: EMBEDDED CONTENT --------------------------------------------------- -const embedded_content: String = " ## Embedded content ### embed | erlang javascript @@ -739,16 +675,12 @@ pub fn portal(attrs: List(Attribute(msg))) -> Element(msg) ```gleam pub fn source(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: SVG AND MATHML ----------------------------------------------------- - -const svg_and_mathml: String = " ## SVG and MathML ### svg | erlang javascript -```gleam +````gleam pub fn svg( attrs: List(Attribute(msg)), children: List(Element(msg)), @@ -761,12 +693,8 @@ pub fn math( attrs: List(Attribute(msg)), children: List(Element(msg)), ) -> Element(msg) -``` -" +```` -// CONTENT: SCRIPTING ---------------------------------------------------------- - -const scripting: String = " ## Scripting ### canvas | erlang javascript @@ -789,11 +717,7 @@ pub fn noscript( ```gleam pub fn script(attrs: List(Attribute(msg)), js: String) -> Element(msg) ``` -" - -// CONTENT: DEMARCATING EDITS -------------------------------------------------- -const demarcating_edits: String = " ## Demarcating edits ### del | erlang javascript @@ -813,11 +737,7 @@ pub fn ins( children: List(Element(msg)), ) -> Element(msg) ``` -" - -// CONTENT: TABLE CONTENT ------------------------------------------------------ -const table_content: String = " ## Table content ### caption | erlang javascript @@ -906,10 +826,7 @@ pub fn tr( children: List(Element(msg)), ) -> Element(msg) ``` -" -// CONTENT: FORMS -------------------------------------------------------------- -const forms: String = " ## Forms ### button | erlang javascript @@ -1028,10 +945,7 @@ pub fn select( ```gleam pub fn textarea(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: INTERACTIVE ELEMENTS ----------------------------------------------- -const interactive_elements: String = " ## Interactive elements ### details | erlang javascript @@ -1060,10 +974,7 @@ pub fn summary( children: List(Element(msg)), ) -> Element(msg) ``` -" -// CONTENT: WEB COMPONENTS ----------------------------------------------------- -const web_components: String = " ## Web components ### slot | erlang javascript @@ -1080,4 +991,3 @@ pub fn template( children: List(Element(msg)), ) -> Element(msg) ``` -" diff --git a/docs/src/app/page/api/lustre/element/svg.gleam b/docs/public/page/api/lustre/element/svg.md index eb4011c..a7a0ec2 100644 --- a/docs/src/app/page/api/lustre/element/svg.gleam +++ b/docs/public/page/api/lustre/element/svg.md @@ -1,116 +1,75 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [ - title, - animation_elements, - basic_shapes, - container_elements, - descriptive_elements, - filter_effects, - gradient_elements, - graphical_elements, - lighting_elements, - non_rendered_elements, - renderable_elements, - ] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " # lustre/element/svg -" -// CONTENT: ANIMATION ELEMENTS ------------------------------------------------ - -const animation_elements: String = " ## Animation elements ### animate | erlang javascript -``` +```gleam pub fn animate(attrs: List(Attribute(msg))) -> Element(msg) ``` ### animate_motion | erlang javascript -``` +```gleam pub fn animate_motion(attrs: List(Attribute(msg))) -> Element(msg) ``` ### animate_transform | erlang javascript -``` +```gleam pub fn animate_transform(attrs: List(Attribute(msg))) -> Element(msg) ``` ### mpath | erlang javascript -``` +```gleam pub fn mpath(attrs: List(Attribute(msg))) -> Element(msg) ``` ### set | erlang javascript -``` +```gleam pub fn set(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: BASIC SHAPES ------------------------------------------------------- - -const basic_shapes: String = " ## Basic shapes ### circle | erlang javascript -``` +```gleam pub fn circle(attrs: List(Attribute(msg))) -> Element(msg) ``` ### ellipse | erlang javascript -``` +```gleam pub fn ellipse(attrs: List(Attribute(msg))) -> Element(msg) ``` ### line | erlang javascript -``` +```gleam pub fn line(attrs: List(Attribute(msg))) -> Element(msg) ``` ### polygon | erlang javascript -``` +```gleam pub fn polygon(attrs: List(Attribute(msg))) -> Element(msg) ``` ### polyline | erlang javascript -``` +```gleam pub fn polyline(attrs: List(Attribute(msg))) -> Element(msg) ``` ### rect | erlang javascript -``` +```gleam pub fn rect(attrs: List(Attribute(msg))) -> Element(msg) ``` -" - -// CONTENT: CONTAINER ELEMENTS ------------------------------------------------- -const container_elements: String = " ## Container elements ### a | erlang javascript @@ -202,11 +161,7 @@ pub fn symbol( children: List(Element(msg)), ) -> Element(msg) ``` -" -// CONTENT: DESCRIPTIVE ELEMENTS ----------------------------------------------- - -const descriptive_elements: String = " ## Descriptive elements ### desc | erlang javascript @@ -235,40 +190,36 @@ pub fn title( children: List(Element(msg)), ) -> Element(msg) ``` -" - -// CONTENT: FILTER EFFECTS ----------------------------------------------------- -const filter_effects: String = " ## Filter effects ### fe_blend | erlang javascript -``` +```gleam pub fn fe_blend(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_color_matrix | erlang javascript -``` +```gleam pub fn fe_color_matrix(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_component_transfer | erlang javascript -``` +```gleam pub fn fe_component_transfer(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_composite | erlang javascript -``` +```gleam pub fn fe_composite(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_convolve_matrix | erlang javascript -``` +```gleam pub fn fe_convolve_matrix(attrs: List(Attribute(msg))) -> Element(msg) ``` @@ -283,55 +234,55 @@ pub fn fe_diffuse_lighting( ### fe_displacement_map | erlang javascript -``` +```gleam pub fn fe_displacement_map(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_drop_shadow | erlang javascript -``` +```gleam pub fn fe_drop_shadow(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_flood | erlang javascript -``` +```gleam pub fn fe_flood(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_func_a | erlang javascript -``` +```gleam pub fn fe_func_a(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_func_b | erlang javascript -``` +```gleam pub fn fe_func_b(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_func_g | erlang javascript -``` +```gleam pub fn fe_func_g(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_func_r | erlang javascript -``` +```gleam pub fn fe_func_r(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_gaussian_blur | erlang javascript -``` +```gleam pub fn fe_gaussian_blur(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_image | erlang javascript -``` +```gleam pub fn fe_image(attrs: List(Attribute(msg))) -> Element(msg) ``` @@ -346,19 +297,19 @@ pub fn fe_merge( ### fe_merge_node | erlang javascript -``` +```gleam pub fn fe_merge_node(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_morphology | erlang javascript -``` +```gleam pub fn fe_morphology(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_offset | erlang javascript -``` +```gleam pub fn fe_offset(attrs: List(Attribute(msg))) -> Element(msg) ``` @@ -382,14 +333,10 @@ pub fn fe_tile( ### fe_turbulence | erlang javascript -``` +```gleam pub fn fe_turbulence(attrs: List(Attribute(msg))) -> Element(msg) ``` -" - -// CONTENT: GRADIENT ELEMENTS -------------------------------------------------- -const gradient_elements: String = " ## Gradient elements ### linear_gradient | erlang javascript @@ -412,69 +359,57 @@ pub fn radial_gradient( ### stop | erlang javascript -``` +```gleam pub fn stop(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: GRAPHICAL ELEMENTS ------------------------------------------------- - -const graphical_elements: String = " ## Graphical elements ### image | erlang javascript -``` +```gleam pub fn image(attrs: List(Attribute(msg))) -> Element(msg) ``` ### path | erlang javascript -``` +```gleam pub fn path(attrs: List(Attribute(msg))) -> Element(msg) ``` ### text | erlang javascript -``` +```gleam pub fn text(attrs: List(Attribute(msg))) -> Element(msg) ``` -### use_ | erlang javascript +### use\_ | erlang javascript -``` +```gleam pub fn use_(attrs: List(Attribute(msg))) -> Element(msg) ``` -" -// CONTENT: LIGHTING ELEMENTS -------------------------------------------------- - -const lighting_elements: String = " ## Lighting elements ### fe_distant_light | erlang javascript -``` +```gleam pub fn fe_distant_light(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_point_light | erlang javascript -``` +```gleam pub fn fe_point_light(attrs: List(Attribute(msg))) -> Element(msg) ``` ### fe_spot_light | erlang javascript -``` +```gleam pub fn fe_spot_light(attrs: List(Attribute(msg))) -> Element(msg) ``` -" - -// CONTENT: NON-RENDERED ELEMENTS ---------------------------------------------- -const non_rendered_elements: String = " -## Non-rendered elements +## Non-rendered elements ### clip_path | erlang javascript @@ -487,20 +422,16 @@ pub fn clip_path( ### script | erlang javascript -``` +```gleam pub fn script(attrs: List(Attribute(msg)), js: String) -> Element(msg) ``` ### style | erlang javascript -``` +```gleam pub fn style(attrs: List(Attribute(msg)), css: String) -> Element(msg) ``` -" - -// CONTENT: RENDERABLE ELEMENTS ------------------------------------------------ -const renderable_elements: String = " ## Renderable elements ### foreign_object | erlang javascript @@ -529,4 +460,3 @@ pub fn tspan( children: List(Element(msg)), ) -> Element(msg) ``` -" diff --git a/docs/src/app/page/api/lustre/event.gleam b/docs/public/page/api/lustre/event.md index 4b098e5..695cb7b 100644 --- a/docs/src/app/page/api/lustre/event.gleam +++ b/docs/public/page/api/lustre/event.md @@ -1,33 +1,5 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [ - title, - mouse_events, - keyboard_events, - form_messages, - focus_events, - custom_events, - ] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " # lustre/event -" -// CONTENT: MOUSE EVENTS ------------------------------------------------------- - -const mouse_events: String = " ## Mouse events ### on_click | erlang javascript @@ -71,11 +43,7 @@ pub fn on_mouse_over(msg: msg) -> Attribute(msg) ```gleam pub fn on_mouse_out(msg: msg) -> Attribute(msg) ``` -" - -// CONTENT: KEYBOARD EVENTS ---------------------------------------------------- -const keyboard_events: String = " ## Keyboard events ### on_keypress | erlang javascript @@ -95,11 +63,7 @@ pub fn on_keydown(msg: fn(String) -> msg) -> Attribute(msg) ```gleam pub fn on_keyup(msg: fn(String) -> msg) -> Attribute(msg) ``` -" - -// CONTENT: FORM MESSAGES ------------------------------------------------------ -const form_messages: String = " ## Form messages ### on_input | erlang javascript @@ -119,11 +83,7 @@ pub fn on_change(msg: fn(Bool) -> msg) -> Attribute(msg) ```gleam pub fn on_submit(msg: msg) -> Attribute(msg) ``` -" -// CONTENT: FOCUS EVENTS ------------------------------------------------------- - -const focus_events: String = " ## Focus events ### on_focus | erlang javascript @@ -137,17 +97,16 @@ pub fn on_focus(msg: msg) -> Attribute(msg) ```gleam pub fn on_blur(msg: msg) -> Attribute(msg) ``` -" - -// CONTENT: CUSTOM EVENTS ------------------------------------------------------ -const custom_events: String = " ## Custom events ### on | erlang javascript ```gleam -pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) +pub fn on( + name: String, + handler: fn(Dynamic) -> Result(msg, error) +) -> Attribute(msg) ``` ### prevent_default | javascript @@ -165,19 +124,19 @@ pub fn stop_propagation(event: Dynamic) -> Nil ### value | erlang javascript ```gleam -pub fn value(event: Dynamic) -> Decoded(String) +pub fn value(event: Dynamic) -> Decoder(String) ``` ### checked | erlang javascript ```gleam -pub fn checked(event: Dynamic) -> Decoded(Bool) +pub fn checked(event: Dynamic) -> Decoder(Bool) ``` ### mouse_position | erlang javascript ```gleam -pub fn mouse_position(event: Dynamic) -> Decoded(#(Float, Float)) +pub fn mouse_position(event: Dynamic) -> Decoder(#(Float, Float)) ``` ### emit | javascript @@ -185,4 +144,3 @@ pub fn mouse_position(event: Dynamic) -> Decoded(#(Float, Float)) ```gleam pub fn emit(event: String, data: any) -> Effect(msg) ``` -" diff --git a/docs/public/page/docs/components.md b/docs/public/page/docs/components.md new file mode 100644 index 0000000..6fecf43 --- /dev/null +++ b/docs/public/page/docs/components.md @@ -0,0 +1,8 @@ +# Components + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/public/page/docs/managing-state.md b/docs/public/page/docs/managing-state.md new file mode 100644 index 0000000..7da5449 --- /dev/null +++ b/docs/public/page/docs/managing-state.md @@ -0,0 +1,8 @@ +# Managing state + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/public/page/docs/quickstart.md b/docs/public/page/docs/quickstart.md new file mode 100644 index 0000000..f706478 --- /dev/null +++ b/docs/public/page/docs/quickstart.md @@ -0,0 +1,272 @@ +# Quickstart + +Lustre is a frontend web framework for Gleam. It is primarily focused on helping +you build robust single-page applications (SPAs), but it can also be used on the +server to render static HTML. To get an idea of what it's all about, here's a +quick overview of Lustre's key features: + +- Elm-inspired runtime with state management and controlled side effects out of + the box. +- A simple, declarative API for building type-safe user interfaces. +- Stateful components built as custom elements and useable just like any other + HTML element. +- Static HTML rendering anywhere Gleam can run: the BEAM, Node.js, Deno, or the + browser. + +In this quickstart guide we'll take a look at how to get up and running with +Lustre in both the browser and on the server. + +## In the browser | javascript + +To get started, we'll scaffold a new Gleam project using `gleam new`. If you've +found your way to this guide but don't already know what Gleam is you can read +about it over at [gleam.run](https://gleam.run). + +```shell +$ gleam new lustre_quickstart && gleam add lustre +``` + +In a real project you probably want to use a build tool like [vite](https://vitejs.dev) +along with the [vite-gleam](https://github.com/Enderchief/vite-gleam) plugin, but +to keep this guide simple we'll just show you what code you need to write and leave +the details on serving the app up to you. MDN have a handy guide covering some +different options to [set up a local web server for development](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Tools_and_setup/set_up_a_local_testing_server) +if you need some ideas. + +### Basic HTML setup + +With our Gleam project scaffolded, go ahead and create an `index.html` in the root +of the project. This is the minimal code you'll typically want to get started: + +```html +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Lustre Quickstart</title> + + <script type="module"> + import { main } from "./build/dev/javascript/lustre_quickstart/app.mjs"; + + document.addEventListener("DOMContentLoaded", () => { + main(); + }); + </script> + </head> + + <body> + <div data-lustre-app></div> + </body> +</html> +``` + +We wait until the DOM has loaded before calling the our app's `main` function. +This will mount the Lustre app and start rendering. We also add the `data-lustre-app` +attribute to the element we want to mount the app to. You could use a class or an +id instead, or none of that: [`lustre.start`](/api/lustre#start) takes a CSS +selector so go wild! + +### Hello, world! + +Go ahead and rename the generated `lustre_quickstart.gleam` file to `app.gleam` +and replace the contents with the following: + +```gleam +import lustre +import lustre/element.{text} + +pub fn main() { + let app = lustre.element(text("Hello, world!")) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil) + + Nil +} +``` + +This will create a static Lustre app and mount it onto the element that matches +the CSS selector. While we're asserting everything is OK here, it is possible +for `lustre.start` to fail in a couple of ways. Check out the docs for the +[`lustre.Error`](/api/lustre#error-type) type if you want to know more. + +Run `gleam build` and serve the HTML with your preferred static file server (this +step is necessary: JavaScript modules can't be imported when just opening a HTML +file) and admire your handiwork. + +### Adding interactivity + +Now that we know how to get things up and running, let's try something a little +more exciting and add some interactivity. Replace the contents of your `app.gleam` +file with the code below and rebuild the project. + +```gleam +import gleam/int +import lustre +import lustre/element.{text} +import lustre/element/html.{div, button, p} +import lustre/event.{on_click} + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start("[data-lustre-app]", Nil) + + Nil +} + +fn init(_) { + 0 +} + +type Msg { + Incr + Decr +} + +fn update(model, msg) { + case msg { + Incr -> model + 1 + Decr -> model - 1 + } +} + +fn view(model) { + let count = int.to_string(model) + + div([], [ + button([on_click(Decr)], [text(" + ")]), + p([], [text(count)]), + button([on_click(Incr)], [text(" - ")]) + ]) +} +``` + +You should know have a very exciting counter app! Almost every Lustre app will +boil down to the same three parts: + +- A `Model` type that represents your application's state and a function to + `init` it. +- A `Msg` type and an `update` function to update that state based on incoming + messages. +- A `view` function that takes the current state and renders some HTML. + +This architecture is not unique to Lustre. It was introduced by the Elm community +and known as the [Elm Architecture](https://guide.elm-lang.org/architecture/) +before making its way to React as [Redux](https://redux.js.org) and beyond, known +more generally as the Model-View-Update architecture. If you work through the +rest of our guides you'll see how this architecture helps keep side effects out +of our view code and how to create components that can encapsulate their own state +and update logic. + +For now though, we'll leave things here. If you're interested in seeing how Lustre +can be used to render static HTML on the server, read on! Otherwise, you can take +this counter application as a base and start building something of your own. +" + +## On the server | erlang javascript + +As we've seen, Lustre is primarily meant to be used in the browser to build +interactive SPAs. It is possible to render Lustre elements to static HTML and +simply use Lustre as a templating DSL. As before, we'll start by scaffolding a +new Gleam project and adding Lustre as a dependency: + +```shell +$ gleam new lustre_quickstart && gleam add lustre +``` + +The [`lustre/element`](/api/lustre/element) module contains functions to render +an element as either a `String` or `StringBuilder`. Copy the following code into +`lustre_quickstart.gleam`: + +```gleam +import gleam/io +import lustre/attribute.{attribute} +import lustre/element.{text} +import lustre/element/html.{html, head, title, body, div, h1} + +pub fn main() { + html([attribute("lang", "en")], [ + head([], [ + title([], [text("Lustre Quickstart")]) + ]), + body([], [ + h1([], [text("Hello, world!")]) + ]) + ]) + |> element.to_string + |> io.println +} +``` + +We can test this out by running `gleam run` and seeing the HTML printed to the +console. From here we could set up a web server using [Mist](/guides/mist) or +[Wisp](/guides/wisp) to serve the HTML to the browser or write it to a file using +[simplifile](https://hexdocs.pm/simplifile/). Because the API is the same for +both client and server rendering, it is easy to create reusable components that +can be rendered anywhere Gleam can run! + +### An example with Wisp + +Before we go, let's just take a quick look at what it would look like to use +Lustre in a [Wisp](https://hexdocs.pm/wisp) application. We won't scaffold out a +real app in this example, but we'll adapt one of the examples from Wisp's own +documentation. + +Specifically, we'll take a look at the `show_form` function from the +["working with form data"](https://github.com/lpil/wisp/blob/ea8a40bc20745f172695c8cc2dc0a63769f890a7/examples/2-working-with-form-data/src/app/router.gleam#L20) +example: + +```gleam +... + +pub fn show_form() -> Response { + // In a larger application a template library or HTML form library might + // be used here instead of a string literal. + let html = + string_builder.from_string( + "<form method='post'> + <label>Title: + <input type='text' name='title'> + </label> + <label>Name: + <input type='text' name='name'> + </label> + <input type='submit' value='Submit'> + </form>", + ) + wisp.ok() + |> wisp.html_body(html) +} +``` + +They've helpfully left a comment telling us that in a larger application we might +want to use a template library, and Lustre is up to the task! Let's refactor this +using Lustre: + +```gleam +import gleam/string +import lustre/attribute.{attribute} +import lustre/element +import lustre/element/html +... + +pub fn show_form() -> Response { + html.form([attribute("method", "post")], [ + labelled_input("Title"), + labelled_input("Name"), + html.input([attribute("type", "submit"), attribute("value", "Submit")]) + ]) + |> element.to_string_builder + |> wisp.html_body + |> wisp.ok +} + +fn labelled_input(name: String) -> Element(Nil) { + html.label([], [ + element.text(name <> ": "), + html.input([ + attribute("type", "text"), + attribute("name", string.lowercase(name)) + ]) + ]) +} +``` diff --git a/docs/public/page/docs/server-side-rendering.md b/docs/public/page/docs/server-side-rendering.md new file mode 100644 index 0000000..66d605c --- /dev/null +++ b/docs/public/page/docs/server-side-rendering.md @@ -0,0 +1,8 @@ +# Server-side rendering + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/public/page/docs/side-effects.md b/docs/public/page/docs/side-effects.md new file mode 100644 index 0000000..1cf21e2 --- /dev/null +++ b/docs/public/page/docs/side-effects.md @@ -0,0 +1,8 @@ +# Side effects + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/public/page/guides/mist.md b/docs/public/page/guides/mist.md new file mode 100644 index 0000000..9afa83b --- /dev/null +++ b/docs/public/page/guides/mist.md @@ -0,0 +1,8 @@ +# Using with Mist + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/public/page/guides/wisp.md b/docs/public/page/guides/wisp.md new file mode 100644 index 0000000..84d4089 --- /dev/null +++ b/docs/public/page/guides/wisp.md @@ -0,0 +1,8 @@ +# Using with Wisp + +Whoopsie, I haven't got round to writing this guide yet. If you haven't checked +out the [quickstart guide](/docs/quickstart) that is probably the best place to +go to get up to speed. + +If you have any questions, feel free to ping `@hayleigh.dev` over on the Gleam +[Discord server](https://discord.gg/Fm8Pwmy) and I'd be happy to help you out! diff --git a/docs/src/app.ffi.mjs b/docs/src/app.ffi.mjs index f5e7f29..3983f27 100644 --- a/docs/src/app.ffi.mjs +++ b/docs/src/app.ffi.mjs @@ -1,95 +1,10 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { attribute } from "../lustre/lustre/attribute.mjs"; -import { element, text } from "../lustre/lustre/element.mjs"; -import { List } from "./gleam.mjs"; -import * as Markdown from "./app/ui/markdown.mjs"; - -// Parsing markdown and then walking the AST is expensive so we're going to trade -// some memory for speed and cache the results. -const cache = new Map(); - -// fn parse_markdown(md: String) -> #(List(Element(msg)), List(Element(msg))) -export const parse_markdown = (md) => { - if (cache.has(md)) return cache.get(md); - - const ast = fromMarkdown(md); - const summary = []; - const content = ast.children.map(function to_lustre_element(node) { - switch (node.type) { - case "code": - return Markdown.code(node.value); - - case "emphasis": - return Markdown.emphasis( - List.fromArray(node.children.map(to_lustre_element)) - ); - - case "heading": { - const [title, rest] = node.children[0].value.split("|"); - const tags = List.fromArray(rest ? rest.trim().split(" ") : []); - const id = - /^[A-Z]/.test(title.trim()) && node.depth === 3 - ? `${title.toLowerCase().trim().replace(/\s/g, "-")}-type` - : `${title.toLowerCase().trim().replace(/\s/g, "-")}`; - - if (node.depth > 1) { - summary.push( - element( - "a", - List.fromArray([ - attribute("href", `#${id}`), - attribute("class", "text-sm text-gray-400 no-underline"), - attribute("class", "hover:text-gray-700 hover:underline"), - attribute( - "class", - node.depth === 2 ? `mt-4 first:mt-0` : `ml-2` - ), - ]), - List.fromArray([text(title.trim())]) - ) - ); - } - - return Markdown.heading(node.depth, title.trim(), tags, id); - } - - case "inlineCode": - return Markdown.inline_code(node.value); - - case "link": - return Markdown.link(node.url, List.fromArray(node.children)); - - case "list": - return Markdown.list( - !!node.ordered, - List.fromArray(node.children.map(to_lustre_element)) - ); - - case "listItem": - return Markdown.list_item( - List.fromArray(node.children.map(to_lustre_element)) - ); - - case "paragraph": - return Markdown.paragraph( - List.fromArray(node.children.map(to_lustre_element)) - ); - - case "strong": - return Markdown.strong( - List.fromArray(node.children.map(to_lustre_element)) - ); - - case "text": - return Markdown.text(node.value); - - default: - return Markdown.text(""); - } - }); - - const result = [List.fromArray(content), List.fromArray(summary)]; - cache.set(md, result); - - return result; +export function base() { + return import.meta.env.BASE_URL; +} + +export const fetch_post = (path, dispatch) => { + fetch(`${import.meta.env.BASE_URL}page/${path.slice(1)}.md`) + .then((res) => res.text()) + .then((content) => dispatch(content)) + .catch(console.error); }; diff --git a/docs/src/app.gleam b/docs/src/app.gleam index a84d040..d448ef7 100644 --- a/docs/src/app.gleam +++ b/docs/src/app.gleam @@ -1,24 +1,18 @@ // IMPORTS --------------------------------------------------------------------- -import app/page/api/lustre as lustre_api -import app/page/api/lustre/attribute as attribute_api -import app/page/api/lustre/effect as effect_api -import app/page/api/lustre/element as element_api -import app/page/api/lustre/element/html as html_api -import app/page/api/lustre/element/svg as svg_api -import app/page/api/lustre/event as event_api -import app/page/docs/components as components_docs -import app/page/docs/managing_state as managing_state_docs -import app/page/docs/quickstart as quickstart_docs -import app/page/docs/server_side_rendering as server_side_rendering_docs -import app/page/docs/side_effects as side_effects_docs +import app/layout +import gleam/function +import gleam/map.{Map} import lustre +import lustre/attribute +import lustre/effect.{Effect} import lustre/element.{Element} +import lustre/element/html // MAIN ------------------------------------------------------------------------ pub fn main(route: Route) -> fn(Msg) -> Nil { - let app = lustre.simple(init, update, view) + let app = lustre.application(init, update, view) let assert Ok(dispatch) = lustre.start(app, "body", route) dispatch @@ -27,51 +21,117 @@ pub fn main(route: Route) -> fn(Msg) -> Nil { // MODEL ----------------------------------------------------------------------- type Model { - Model(route: Route) + Model(route: Route, content: String, history: Map(String, String)) } -fn init(route: Route) -> Model { - Model(route) +fn init(route: Route) -> #(Model, Effect(Msg)) { + let content = "" + let history = map.new() + let model = Model(route, content, history) + let effects = case route.path { + "/" -> fetch_post("/docs/quickstart", history) + _ -> fetch_post(route.path, history) + } + + #(model, effects) } // UPDATE ---------------------------------------------------------------------- pub type Msg { OnRouteChange(Route) + GotPost(String, String) } pub type Route { Route(path: String, hash: String) } -fn update(model: Model, msg: Msg) -> Model { +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + // We need to do this because Gleam doesn't support record field access in + // guards just yet. + let current_path = model.route.path + case msg { - OnRouteChange(route) -> Model(..model, route: route) + OnRouteChange(Route(path: "/", hash: _) as route) -> #( + Model(..model, route: route), + fetch_post("/docs/quickstart", model.history), + ) + + // Only fetch the markdown + OnRouteChange(Route(path: path, hash: _) as route) if path != current_path -> #( + Model(..model, route: route), + fetch_post(path, model.history), + ) + + GotPost(path, content) -> #( + Model( + ..model, + content: content, + history: map.insert(model.history, path, content), + ), + effect.none(), + ) + } +} + +fn fetch_post(path: String, history: Map(String, String)) -> Effect(Msg) { + use dispatch <- effect.from + + case map.get(history, path) { + Ok(content) -> dispatch(GotPost(path, content)) + Error(_) -> + fetch_post_content(path, function.compose(GotPost(path, _), dispatch)) } } +@external(javascript, "./app.ffi.mjs", "fetch_post") +fn fetch_post_content(path: String, dispatch: fn(String) -> Nil) -> Nil + // VIEW ------------------------------------------------------------------------ fn view(model: Model) -> Element(Msg) { case model.route.path { - "/" -> quickstart_docs.view() - - "/api" -> lustre_api.view() - "/api/lustre" -> lustre_api.view() - "/api/lustre/attribute" -> attribute_api.view() - "/api/lustre/effect" -> effect_api.view() - "/api/lustre/element" -> element_api.view() - "/api/lustre/element/html" -> html_api.view() - "/api/lustre/element/svg" -> svg_api.view() - "/api/lustre/event" -> event_api.view() - - "/docs" -> quickstart_docs.view() - "/docs/quickstart" -> quickstart_docs.view() - "/docs/managing-state" -> managing_state_docs.view() - "/docs/side-effects" -> side_effects_docs.view() - "/docs/components" -> components_docs.view() - "/docs/server-side-rendering" -> server_side_rendering_docs.view() - - _ -> quickstart_docs.view() + "/" -> + html.body( + [], + [ + html.div( + [ + attribute.class( + "w-screen h-screen flex justify-center items-center", + ), + attribute.style([ + #("background-color", "hsla(226,0%,100%,1)"), + #( + "background-image", + " radial-gradient(at 62% 13%, hsla(170,76%,60%,1) 0px, transparent 65%), + radial-gradient(at 67% 42%, hsla(234,89%,70%,1) 0px, transparent 65%), + radial-gradient(at 10% 7%, hsla(213,93%,57%,1) 0px, transparent 65%), + radial-gradient(at 32% 46%, hsla(291,93%,80%,1) 0px, transparent 65%)", + ), + ]), + ], + [ + html.hgroup( + [], + [ + html.h1( + [attribute.class("text-8xl")], + [element.text("Lustre.")], + ), + html.p( + [attribute.class("pl-1")], + [element.text("Web apps from space!")], + ), + ], + ), + ], + ), + layout.docs_section(model.content), + ], + ) + + _ -> layout.docs_page(model.content) } } diff --git a/docs/src/app/effects.gleam b/docs/src/app/effects.gleam new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/docs/src/app/effects.gleam diff --git a/docs/src/app/layout.gleam b/docs/src/app/layout.gleam index 9f3b294..5ec9222 100644 --- a/docs/src/app/layout.gleam +++ b/docs/src/app/layout.gleam @@ -1,19 +1,106 @@ // IMPORTS --------------------------------------------------------------------- +import app/ui/hooks +import app/ui/radix +import app/ui/markdown import gleam/list +import gleam/string import lustre/attribute import lustre/element.{Element} import lustre/element/html +import lustre/event -pub fn docs(md: String) -> Element(msg) { - let #(content, summary) = parse_markdown(md) +pub fn docs_page(md: String) -> Element(msg) { + let #(content, summary) = markdown.parse(md) html.body( - [attribute.class("bg-gray-50 prose max-w-none")], + [attribute.class("prose prose-lustre max-w-none")], [ html.div( [attribute.class("max-w-[96rem] mx-auto grid grid-cols-8")], - [docs_left(), docs_content(content), docs_right(summary)], + [docs_top(), docs_left(), docs_content(content), docs_right(summary)], + ), + ], + ) +} + +pub fn docs_section(md: String) -> Element(msg) { + let #(content, summary) = markdown.parse(md) + + html.div( + [attribute.class("prose prose-lustre max-w-none")], + [ + html.div( + [attribute.class("max-w-[96rem] mx-auto grid grid-cols-8")], + [docs_top(), docs_left(), docs_content(content), docs_right(summary)], + ), + ], + ) +} + +fn docs_top() -> Element(msg) { + html.header( + [attribute.class("sticky top-0 z-10 col-span-8 lg:hidden bg-white")], + [docs_top_toggle()], + ) +} + +fn docs_top_toggle() -> Element(msg) { + use open, set_open, _ <- hooks.use_state(False) + + case open { + True -> docs_top_open(set_open(False)) + False -> docs_top_closed(set_open(True)) + } +} + +fn docs_top_open(close: msg) -> Element(msg) { + html.div( + [attribute.class("relative")], + [ + html.div( + [attribute.class("flex justify-between items-center px-4 py-2")], + [ + html.h2( + [attribute.class("text-indigo-600 my-0")], + [element.text("Lustre.")], + ), + html.button( + [ + event.on_click(close), + attribute.class("hover:bg-gray-200 rounded p-2"), + ], + [radix.cross([attribute.class("w-4 h-4")])], + ), + ], + ), + html.nav( + [ + attribute.class( + "absolute top-0 w-full rounded-b-2xl px-4 mt-12 bg-white shadow", + ), + ], + docs_left_links(), + ), + ], + ) +} + +fn docs_top_closed(open: msg) -> Element(msg) { + html.div( + [ + attribute.class( + "flex justify-between items-center px-4 py-2 border-b shadow", + ), + ], + [ + html.h2( + [attribute.class("text-indigo-600 my-0")], + [element.text("Lustre.")], + ), + html.button( + [event.on_click(open), attribute.class("hover:bg-gray-100 rounded p-2")], + [radix.hamburger([attribute.class("w-4 h-4")])], ), ], ) @@ -23,7 +110,7 @@ fn docs_left() -> Element(msg) { html.aside( [ attribute.style([#("align-self", "start")]), - attribute.class("sticky top-0 border-r hidden px-4 h-screen"), + attribute.class("relative sticky top-0 hidden px-4 pb-10 h-screen"), attribute.class("lg:block lg:col-span-2"), attribute.class("xl:col-span-2"), ], @@ -31,46 +118,86 @@ fn docs_left() -> Element(msg) { html.div( [ attribute.class( - "absolute right-0 inset-y-0 w-[50vw] bg-gray-100 -z-10", + "absolute right-0 inset-y-0 w-[50vw] bg-gradient-to-b from-white to-gray-100 -z-10", ), ], [], ), - html.h2([attribute.class("text-indigo-600")], [element.text("Lustre.")]), - docs_left_section( - "Docs", - [ - #("Quickstart", "/docs/quickstart"), - #("Managing state", "/docs/managing-state"), - #("Side effects", "/docs/side-effects"), - #("Components", "/docs/components"), - #("Server-side rendering", "/docs/server-side-rendering"), - ], - ), - docs_left_section( - "Reference", - [ - #("lustre", "/api/lustre"), - #("lustre/attribute", "/api/lustre/attribute"), - #("lustre/effect", "/api/lustre/effect"), - #("lustre/element", "/api/lustre/element"), - #("lustre/element/html", "/api/lustre/element/html"), - #("lustre/element/svg", "/api/lustre/element/svg"), - #("lustre/event", "/api/lustre/event"), - ], - ), - docs_left_section( - "External", + html.div( + [attribute.class("flex flex-col h-full overflow-y-scroll")], [ - #("GitHub", "https://github.com/hayleigh-dot-dev/gleam-lustre"), - #("Discord", "https://discord.gg/Fm8Pwmy"), - #("Buy me a coffee?", "https://github.com/sponsors/hayleigh-dot-dev"), + html.h2( + [attribute.class("mb-0")], + [ + html.a( + [ + attribute.href("/"), + attribute.class("text-indigo-600 no-underline"), + ], + [element.text("Lustre")], + ), + ], + ), + html.p( + [attribute.class("text-gray-400 font-bold")], + [element.text("Web apps from space.")], + ), + ..docs_left_links() ], ), ], ) } +fn docs_left_links() -> List(Element(msg)) { + let link = string.append(base_url(), _) + + [ + docs_left_section( + "Docs", + [ + #("Quickstart", link("docs/quickstart")), + #("Managing state", link("docs/managing-state")), + #("Side effects", link("docs/side-effects")), + #("Components", link("docs/components")), + #("Server-side rendering", link("docs/server-side-rendering")), + ], + ), + docs_left_section( + "Guides", + [ + #("Using with Mist", link("guides/mist")), + #("Using with Wisp", link("guides/wisp")), + ], + ), + docs_left_section( + "Reference", + [ + #("lustre", link("api/lustre")), + #("lustre/attribute", link("api/lustre/attribute")), + #("lustre/effect", link("api/lustre/effect")), + #("lustre/element", link("api/lustre/element")), + #("lustre/element/html", link("api/lustre/element/html")), + #("lustre/element/svg", link("api/lustre/element/svg")), + #("lustre/event", link("api/lustre/event")), + ], + ), + docs_left_section( + "External", + [ + #("GitHub", "https://github.com/hayleigh-dot-dev/gleam-lustre"), + #("Discord", "https://discord.gg/Fm8Pwmy"), + #("Buy me a coffee?", "https://github.com/sponsors/hayleigh-dot-dev"), + ], + ), + ] +} + +@external(javascript, "../app.ffi.mjs", "base") +fn base_url() -> String { + "/" +} + fn docs_left_section( title: String, pages: List(#(String, String)), @@ -78,12 +205,20 @@ fn docs_left_section( html.nav( [], [ - html.h2([], [element.text(title)]), + html.h2([attribute.class("my-0 lg:mt-8 lg:mb-4")], [element.text(title)]), html.ul( [attribute.class("ml-2")], { use #(name, url) <- list.map(pages) - html.li([], [html.a([attribute.href(url)], [element.text(name)])]) + html.li( + [], + [ + html.a( + [attribute.href(url), attribute.class("font-serif")], + [element.text(name)], + ), + ], + ) }, ), ], @@ -105,19 +240,22 @@ fn docs_right(summary: List(Element(msg))) -> Element(msg) { html.aside( [ attribute.style([#("align-self", "start")]), - attribute.class("sticky top-0 border-l hidden p-4 py-10 h-screen"), + attribute.class("sticky relative top-0 hidden p-4 py-10 h-screen"), attribute.class("xl:block xl:col-span-1"), ], [ html.div( + [ + attribute.class( + "absolute left-0 inset-y-0 w-[50vw] bg-gradient-to-b from-white to-gray-100 -z-10", + ), + ], + [], + ), + html.div( [attribute.class("flex flex-col h-full overflow-y-scroll")], summary, ), ], ) } - -// EXTERNALS ------------------------------------------------------------------- - -@external(javascript, "../app.ffi.mjs", "parse_markdown") -fn parse_markdown(md: String) -> #(List(Element(msg)), List(Element(msg))) diff --git a/docs/src/app/page/api/lustre.gleam b/docs/src/app/page/api/lustre.gleam deleted file mode 100644 index 63f8547..0000000 --- a/docs/src/app/page/api/lustre.gleam +++ /dev/null @@ -1,116 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title, applications, components, utilities] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# lustre -" - -const applications: String = " -## Applications - -### App | erlang javascript - -```gleam -pub type App(flags, model, msg) -``` - -### Error | erlang javascript - -```gleam -pub type Error { - AppAlreadyStarted - AppNotYetStarted - ComponentAlreadyRegistered - ElementNotFound - NotABrowser -} -``` - -### element | javascript - -```gleam -pub fn element(el: Element(msg)) -> App(Nil, Nil, msg) -``` - -### simple | javascript - -```gleam -pub fn simple( - init: fn(flags) -> model, - update: fn(model, msg) -> model, - view: fn(model) -> Element(msg) -) -> App(flags, model, msg) -``` - -### application | javascript - -```gleam -pub fn application( - init: fn(flags) -> #(model, Effect(msg)), - update: fn(model, msg) -> #(model, Effect(msg)), - view: fn(model) -> Element(msg) -) -> App(flags, model, msg) -``` - -### start | javascript - -```gleam -pub fn start( - app: App(flags, model, msg), - selector: String, - flags: flags, -) -> Result(fn(msg) -> Nil, Error) -``` - -### destroy | javascript - -```gleam -pub fn destroy(app: App(flags, model, msg)) -> Nil -``` -" - -const components: String = " -## Components - -### component | javascript - -```gleam -pub fn component( - name: String, - init: fn() -> #(model, Effect(msg)), - update: fn(model, msg) -> #(model, Effect(msg)), - view: fn(model) -> Element(msg), - on_attribute_change: Map(String, Decoder(msg)), -) -> Result(Nil, Error) -``` -" - -const utilities: String = " -## Utilities - -### is_browser | erlang javascript - -```gleam -pub fn is_browser() -> Bool -``` - -### is_registered | erlang javascript - -```gleam -pub fn is_registered(_name: String) -> Bool -``` - -" diff --git a/docs/src/app/page/api/lustre/effect.gleam b/docs/src/app/page/api/lustre/effect.gleam deleted file mode 100644 index ea3e1af..0000000 --- a/docs/src/app/page/api/lustre/effect.gleam +++ /dev/null @@ -1,61 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title, constructing_effects, manipulating_effects] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# lustre/effect -" - -// CONTENT: CONSTRUCTING EFFECTS ------------------------------------------------ - -const constructing_effects: String = " -## Constructing Effects - -### Effect | erlang javascript - -```gleam -pub opaque type Effect(action) -``` - -### from | erlang javascript - -```gleam -pub fn from(effect: fn(fn(action) -> Nil) -> Nil) -> Effect(action) -``` - -### none | erlang javascript - -```gleam -pub fn none() -> Effect(action) -``` - -### batch | erlang javascript - -```gleam -pub fn batch(effects: List(Effect(action))) -> Effect(action) -``` -" - -// CONTENT: MANIPULATING EFFECTS ----------------------------------------------- - -const manipulating_effects: String = " -## Manipulating Effects - -### map | erlang javascript - -```gleam -pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) -``` -" diff --git a/docs/src/app/page/api/lustre/element.gleam b/docs/src/app/page/api/lustre/element.gleam deleted file mode 100644 index 69a1273..0000000 --- a/docs/src/app/page/api/lustre/element.gleam +++ /dev/null @@ -1,88 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title, constructing_elements, mapping_elements, conversions] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# lustre/element -" - -// CONTENT: CONSTRUCTING ELEMENTS ---------------------------------------------- - -const constructing_elements: String = " -## Constructing elements - -### Element | erlang javascript - -```gleam -pub opaque type Element(msg) -``` - -### element | erlang javascript - -```gleam -pub fn element( - tag: String, - attrs: List(Attribute(msg)), - children: List(Element(msg)), -) -> Element(msg) -``` - -### namespaced | erlang javascript - -```gleam -pub fn namespaced( - namespace: String, - tag: String, - attrs: List(Attribute(msg)), - children: List(Element(msg)), -) -> Element(msg) -``` - -### text | erlang javascript - -```gleam -pub fn text(content: String) -> Element(msg) -``` -" - -// CONTENT: MAPPING ELEMENTS --------------------------------------------------- - -const mapping_elements: String = " -## Mapping elements - -### map | erlang javascript - -```gleam -pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) -``` -" - -// CONTENT: CONVERSIONS -------------------------------------------------------- - -const conversions: String = " -## Conversions - -### to_string | erlang javascript - -```gleam -pub fn to_string(element: Element(msg)) -> String -``` - -### to_string_builder | erlang javascript - -```gleam -pub fn to_string_builder(element: Element(msg)) -> StringBuilder -``` -" diff --git a/docs/src/app/page/docs/components.gleam b/docs/src/app/page/docs/components.gleam deleted file mode 100644 index 765443d..0000000 --- a/docs/src/app/page/docs/components.gleam +++ /dev/null @@ -1,19 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# Components -" diff --git a/docs/src/app/page/docs/managing_state.gleam b/docs/src/app/page/docs/managing_state.gleam deleted file mode 100644 index e337b20..0000000 --- a/docs/src/app/page/docs/managing_state.gleam +++ /dev/null @@ -1,19 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# Managing state -" diff --git a/docs/src/app/page/docs/quickstart.gleam b/docs/src/app/page/docs/quickstart.gleam deleted file mode 100644 index e78b97f..0000000 --- a/docs/src/app/page/docs/quickstart.gleam +++ /dev/null @@ -1,21 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import gleam/io -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title] - |> io.debug - |> string.join("\n") - |> io.debug - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = "# Quickstart -" diff --git a/docs/src/app/page/docs/server_side_rendering.gleam b/docs/src/app/page/docs/server_side_rendering.gleam deleted file mode 100644 index 561b532..0000000 --- a/docs/src/app/page/docs/server_side_rendering.gleam +++ /dev/null @@ -1,19 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# Server-side rendering -" diff --git a/docs/src/app/page/docs/side_effects.gleam b/docs/src/app/page/docs/side_effects.gleam deleted file mode 100644 index 965ec77..0000000 --- a/docs/src/app/page/docs/side_effects.gleam +++ /dev/null @@ -1,19 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import app/layout -import gleam/string -import lustre/element.{Element} - -// PAGE ------------------------------------------------------------------------ - -pub fn view() -> Element(msg) { - [title] - |> string.join("\n") - |> layout.docs -} - -// CONTENT: TITLE -------------------------------------------------------------- - -const title: String = " -# Side effects -" diff --git a/docs/src/app/ui/hooks.gleam b/docs/src/app/ui/hooks.gleam new file mode 100644 index 0000000..5fd807e --- /dev/null +++ b/docs/src/app/ui/hooks.gleam @@ -0,0 +1,120 @@ +// 🚨 This module makes quite judicious use of `dynamic.unsafe_coerce` to wire +// things up. As things are, this is sound because we control things in such a +// way that it's impossible to pass in things that don't match up to the expected +// types. +// +// If you're defining a new hook to export for this module, pay extra attention +// to make sure you aren't introducing any soundness issues! 🚨 + +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{Dynamic, dynamic} +import gleam/function +import gleam/map.{Map} +import gleam/result +import lustre.{Error} +import lustre/attribute.{Attribute, property} +import lustre/effect.{Effect} +import lustre/element.{Element, element} +import lustre/event + +// HOOKS: STATE ---------------------------------------------------------------- + +/// +/// +pub fn use_state( + init: state, + view: fn(state, fn(state) -> Msg, fn(msg) -> Msg) -> Element(Msg), +) -> Element(msg) { + let attrs = [property("state", init), property("view", view), on_dispatch()] + let assert Ok(_) = register_hook("use-state") + + element("use-state", attrs, []) +} + +// HOOKS: REDUCER -------------------------------------------------------------- + +/// +/// +pub fn use_reducer( + init: state, + update: fn(state, action) -> state, + view: fn(state, fn(action) -> Msg, fn(action) -> Msg) -> Element(Msg), +) -> Element(msg) { + // The `use_reducer` hook is actually just the `use_state` hook under the hood + // with a wrapper around the `set_state` callback. We could just call out to + // `use_state` directly but we're doing it like this so that the DOM renders + // a separate `use-reducer` element, which I think is nicer. + let view = fn(state, set_state, emit) { + view(state, function.compose(update(state, _), set_state), emit) + } + let attrs = [property("state", init), property("view", view), on_dispatch()] + let assert Ok(_) = register_hook("use-reducer") + + element("use-reducer", attrs, []) +} + +// HOOKS: INTERNAL COMPONENT --------------------------------------------------- + +fn register_hook(name: String) -> Result(Nil, Error) { + // If a component is already registered we will just assume it's because this + // hook as already been used before. This isn't really an error state so we'll + // just return `Ok(Nil)` and let our hooks continue. + case lustre.is_registered(name) { + True -> Ok(Nil) + False -> + lustre.component( + name, + init_hook, + update_hook, + view_hook, + map.from_list([ + #("state", dynamic.decode1(Set("state", _), Ok)), + #("view", dynamic.decode1(Set("view", _), Ok)), + ]), + ) + } +} + +type Model = + Map(String, Dynamic) + +fn init_hook() -> #(Model, Effect(msg)) { + #(map.new(), effect.none()) +} + +/// The type for messages handled internally by the different hooks. You typially +/// won't need to import or refer to this type directly. +/// +pub opaque type Msg { + Set(String, Dynamic) + Emit(Dynamic) +} + +fn update_hook(model: Model, msg: Msg) -> #(Model, Effect(msg)) { + case msg { + Set(key, val) -> #(map.insert(model, key, val), effect.none()) + Emit(msg) -> #(model, event.emit("dispatch", msg)) + } +} + +fn view_hook(model: Model) -> Element(Msg) { + case map.get(model, "state"), map.get(model, "view") { + Ok(state), Ok(view) -> { + let state = dynamic.unsafe_coerce(state) + let view = dynamic.unsafe_coerce(view) + + view(state, Set("state", _), Emit) + } + _, _ -> element.text("???") + } +} + +// EVENTS ---------------------------------------------------------------------- + +fn on_dispatch() -> Attribute(msg) { + use event <- event.on("dispatch") + event + |> dynamic.field("detail", dynamic) + |> result.map(dynamic.unsafe_coerce) +} diff --git a/docs/src/app/ui/markdown.gleam b/docs/src/app/ui/markdown.gleam index 8bc00da..5a78d0f 100644 --- a/docs/src/app/ui/markdown.gleam +++ b/docs/src/app/ui/markdown.gleam @@ -2,14 +2,34 @@ import gleam/int import gleam/list -import lustre/attribute -import lustre/element.{Element} +import lustre/attribute.{attribute} +import lustre/element.{Element, element} import lustre/element/html +@external(javascript, "../../markdown.ffi.mjs", "parse_markdown") +pub fn parse(md: String) -> #(List(Element(msg)), List(Element(msg))) + // MARKDOWN ELEMENTS ----------------------------------------------------------- +// +// These are used in the FFI markdown renderer to convert the markdown AST into +// lustre elements. +// -pub fn code(src: String) -> Element(msg) { - html.pre([], [html.code([], [element.text(src)])]) +pub fn code(src: String, hash: String, lang: String) -> Element(msg) { + html.pre( + [attribute.class("not-prose rounded-xl")], + [ + html.code( + [ + attribute("data-hash", hash), + attribute("data-lang", lang), + attribute.class("language-" <> lang), + attribute.style([#("background", "transparent")]), + ], + [element.text(src)], + ), + ], + ) } pub fn emphasis(content: List(Element(msg))) { @@ -31,7 +51,7 @@ pub fn heading( [attribute.class("flex items-center justify-between"), attribute.id(id)], [ heading_title(title, id), - html.p([attribute.class("flex gap-4")], tags), + html.p([attribute.class("flex gap-4 font-sans")], tags), ], ) 2 -> @@ -42,7 +62,7 @@ pub fn heading( ], [ heading_title(title, id), - html.p([attribute.class("flex gap-4")], tags), + html.p([attribute.class("flex gap-4 font-sans")], tags), ], ) 3 -> @@ -50,7 +70,7 @@ pub fn heading( [attribute.class("flex items-center justify-between"), attribute.id(id)], [ heading_title(title, id), - html.p([attribute.class("flex gap-2")], tags), + html.p([attribute.class("flex gap-2 font-sans")], tags), ], ) } diff --git a/docs/src/app/ui/radix.gleam b/docs/src/app/ui/radix.gleam new file mode 100644 index 0000000..d6c9755 --- /dev/null +++ b/docs/src/app/ui/radix.gleam @@ -0,0 +1,74 @@ +// All these icons are created by the amazing folks at Radix, and can be found +// here: https://www.radix-ui.com/icons +// + +// IMPORTS --------------------------------------------------------------------- + +import lustre/attribute.{Attribute, attribute} +import lustre/element.{Element} +import lustre/element/html.{svg} +import lustre/element/svg + +// BASE ICON ------------------------------------------------------------------- + +fn icon(attrs: List(Attribute(msg)), path: String) -> Element(msg) { + svg( + [attribute("viewBox", "0 0 15 15"), attribute("fill", "none"), ..attrs], + [ + svg.path([ + attribute("d", path), + attribute("fill", "currentColor"), + attribute("fill-rule", "evenodd"), + attribute("clip-rule", "evenodd"), + ]), + ], + ) +} + +// LOGOS ----------------------------------------------------------------------- + +pub fn github(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M7.49933 0.25C3.49635 0.25 0.25 3.49593 0.25 7.50024C0.25 10.703 2.32715 13.4206 5.2081 14.3797C5.57084 14.446 5.70302 14.2222 5.70302 14.0299C5.70302 13.8576 5.69679 13.4019 5.69323 12.797C3.67661 13.235 3.25112 11.825 3.25112 11.825C2.92132 10.9874 2.44599 10.7644 2.44599 10.7644C1.78773 10.3149 2.49584 10.3238 2.49584 10.3238C3.22353 10.375 3.60629 11.0711 3.60629 11.0711C4.25298 12.1788 5.30335 11.8588 5.71638 11.6732C5.78225 11.205 5.96962 10.8854 6.17658 10.7043C4.56675 10.5209 2.87415 9.89918 2.87415 7.12104C2.87415 6.32925 3.15677 5.68257 3.62053 5.17563C3.54576 4.99226 3.29697 4.25521 3.69174 3.25691C3.69174 3.25691 4.30015 3.06196 5.68522 3.99973C6.26337 3.83906 6.8838 3.75895 7.50022 3.75583C8.1162 3.75895 8.73619 3.83906 9.31523 3.99973C10.6994 3.06196 11.3069 3.25691 11.3069 3.25691C11.7026 4.25521 11.4538 4.99226 11.3795 5.17563C11.8441 5.68257 12.1245 6.32925 12.1245 7.12104C12.1245 9.9063 10.4292 10.5192 8.81452 10.6985C9.07444 10.9224 9.30633 11.3648 9.30633 12.0413C9.30633 13.0102 9.29742 13.7922 9.29742 14.0299C9.29742 14.2239 9.42828 14.4496 9.79591 14.3788C12.6746 13.4179 14.75 10.7025 14.75 7.50024C14.75 3.49593 11.5036 0.25 7.49933 0.25Z", + ) +} + +pub fn discord(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M5.07451 1.82584C5.03267 1.81926 4.99014 1.81825 4.94803 1.82284C4.10683 1.91446 2.82673 2.36828 2.07115 2.77808C2.02106 2.80525 1.97621 2.84112 1.93869 2.88402C1.62502 3.24266 1.34046 3.82836 1.11706 4.38186C0.887447 4.95076 0.697293 5.55032 0.588937 5.98354C0.236232 7.39369 0.042502 9.08728 0.0174948 10.6925C0.0162429 10.7729 0.0351883 10.8523 0.0725931 10.9234C0.373679 11.496 1.02015 12.027 1.66809 12.4152C2.32332 12.8078 3.08732 13.1182 3.70385 13.1778C3.85335 13.1922 4.00098 13.1358 4.10282 13.0255C4.2572 12.8581 4.5193 12.4676 4.71745 12.1643C4.80739 12.0267 4.89157 11.8953 4.95845 11.7901C5.62023 11.9106 6.45043 11.9801 7.50002 11.9801C8.54844 11.9801 9.37796 11.9107 10.0394 11.7905C10.1062 11.8957 10.1903 12.0269 10.2801 12.1643C10.4783 12.4676 10.7404 12.8581 10.8947 13.0255C10.9966 13.1358 11.1442 13.1922 11.2937 13.1778C11.9102 13.1182 12.6742 12.8078 13.3295 12.4152C13.9774 12.027 14.6239 11.496 14.925 10.9234C14.9624 10.8523 14.9813 10.7729 14.9801 10.6925C14.9551 9.08728 14.7613 7.39369 14.4086 5.98354C14.3003 5.55032 14.1101 4.95076 13.8805 4.38186C13.6571 3.82836 13.3725 3.24266 13.0589 2.88402C13.0214 2.84112 12.9765 2.80525 12.9264 2.77808C12.1708 2.36828 10.8907 1.91446 10.0495 1.82284C10.0074 1.81825 9.96489 1.81926 9.92305 1.82584C9.71676 1.85825 9.5391 1.96458 9.40809 2.06355C9.26977 2.16804 9.1413 2.29668 9.0304 2.42682C8.86968 2.61544 8.71437 2.84488 8.61428 3.06225C8.27237 3.03501 7.90138 3.02 7.5 3.02C7.0977 3.02 6.72593 3.03508 6.38337 3.06244C6.28328 2.84501 6.12792 2.61549 5.96716 2.42682C5.85626 2.29668 5.72778 2.16804 5.58947 2.06355C5.45846 1.96458 5.2808 1.85825 5.07451 1.82584ZM11.0181 11.5382C11.0395 11.5713 11.0615 11.6051 11.0838 11.6392C11.2169 11.843 11.3487 12.0385 11.4508 12.1809C11.8475 12.0916 12.352 11.8818 12.8361 11.5917C13.3795 11.2661 13.8098 10.8918 14.0177 10.5739C13.9852 9.06758 13.7993 7.50369 13.4773 6.21648C13.38 5.82759 13.2038 5.27021 12.9903 4.74117C12.7893 4.24326 12.5753 3.82162 12.388 3.5792C11.7376 3.24219 10.7129 2.88582 10.0454 2.78987C10.0308 2.79839 10.0113 2.81102 9.98675 2.82955C9.91863 2.881 9.84018 2.95666 9.76111 3.04945C9.71959 3.09817 9.68166 3.1471 9.64768 3.19449C9.953 3.25031 10.2253 3.3171 10.4662 3.39123C11.1499 3.6016 11.6428 3.89039 11.884 4.212C12.0431 4.42408 12.0001 4.72494 11.788 4.884C11.5759 5.04306 11.2751 5.00008 11.116 4.788C11.0572 4.70961 10.8001 4.4984 10.1838 4.30877C9.58933 4.12585 8.71356 3.98 7.5 3.98C6.28644 3.98 5.41067 4.12585 4.81616 4.30877C4.19988 4.4984 3.94279 4.70961 3.884 4.788C3.72494 5.00008 3.42408 5.04306 3.212 4.884C2.99992 4.72494 2.95694 4.42408 3.116 4.212C3.35721 3.89039 3.85011 3.6016 4.53383 3.39123C4.77418 3.31727 5.04571 3.25062 5.35016 3.19488C5.31611 3.14738 5.27808 3.09831 5.23645 3.04945C5.15738 2.95666 5.07893 2.881 5.01081 2.82955C4.98628 2.81102 4.96674 2.79839 4.95217 2.78987C4.28464 2.88582 3.25999 3.24219 2.60954 3.5792C2.42226 3.82162 2.20825 4.24326 2.00729 4.74117C1.79376 5.27021 1.61752 5.82759 1.52025 6.21648C1.19829 7.50369 1.01236 9.06758 0.97986 10.5739C1.18772 10.8918 1.61807 11.2661 2.16148 11.5917C2.64557 11.8818 3.15003 12.0916 3.5468 12.1809C3.64885 12.0385 3.78065 11.843 3.9138 11.6392C3.93626 11.6048 3.95838 11.5708 3.97996 11.5375C3.19521 11.2591 2.77361 10.8758 2.50064 10.4664C2.35359 10.2458 2.4132 9.94778 2.63377 9.80074C2.85435 9.65369 3.15236 9.71329 3.29941 9.93387C3.56077 10.3259 4.24355 11.0201 7.50002 11.0201C10.7565 11.0201 11.4392 10.326 11.7006 9.93386C11.8477 9.71329 12.1457 9.65369 12.3663 9.80074C12.5869 9.94779 12.6465 10.2458 12.4994 10.4664C12.2262 10.8762 11.8041 11.2598 11.0181 11.5382ZM4.08049 7.01221C4.32412 6.74984 4.65476 6.60162 5.00007 6.59998C5.34538 6.60162 5.67603 6.74984 5.91966 7.01221C6.16329 7.27459 6.30007 7.62974 6.30007 7.99998C6.30007 8.37021 6.16329 8.72536 5.91966 8.98774C5.67603 9.25011 5.34538 9.39833 5.00007 9.39998C4.65476 9.39833 4.32412 9.25011 4.08049 8.98774C3.83685 8.72536 3.70007 8.37021 3.70007 7.99998C3.70007 7.62974 3.83685 7.27459 4.08049 7.01221ZM9.99885 6.59998C9.65354 6.60162 9.3229 6.74984 9.07926 7.01221C8.83563 7.27459 8.69885 7.62974 8.69885 7.99998C8.69885 8.37021 8.83563 8.72536 9.07926 8.98774C9.3229 9.25011 9.65354 9.39833 9.99885 9.39998C10.3442 9.39833 10.6748 9.25011 10.9184 8.98774C11.1621 8.72536 11.2989 8.37021 11.2989 7.99998C11.2989 7.62974 11.1621 7.27459 10.9184 7.01221C10.6748 6.74984 10.3442 6.60162 9.99885 6.59998Z", + ) +} + +// ABSTRACT -------------------------------------------------------------------- + +pub fn hamburger(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z", + ) +} + +pub fn cross(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M12.8536 2.85355C13.0488 2.65829 13.0488 2.34171 12.8536 2.14645C12.6583 1.95118 12.3417 1.95118 12.1464 2.14645L7.5 6.79289L2.85355 2.14645C2.65829 1.95118 2.34171 1.95118 2.14645 2.14645C1.95118 2.34171 1.95118 2.65829 2.14645 2.85355L6.79289 7.5L2.14645 12.1464C1.95118 12.3417 1.95118 12.6583 2.14645 12.8536C2.34171 13.0488 2.65829 13.0488 2.85355 12.8536L7.5 8.20711L12.1464 12.8536C12.3417 13.0488 12.6583 13.0488 12.8536 12.8536C13.0488 12.6583 13.0488 12.3417 12.8536 12.1464L8.20711 7.5L12.8536 2.85355Z", + ) +} + +// OBJECTS --------------------------------------------------------------------- + +pub fn sun(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M7.5 0C7.77614 0 8 0.223858 8 0.5V2.5C8 2.77614 7.77614 3 7.5 3C7.22386 3 7 2.77614 7 2.5V0.5C7 0.223858 7.22386 0 7.5 0ZM2.1967 2.1967C2.39196 2.00144 2.70854 2.00144 2.90381 2.1967L4.31802 3.61091C4.51328 3.80617 4.51328 4.12276 4.31802 4.31802C4.12276 4.51328 3.80617 4.51328 3.61091 4.31802L2.1967 2.90381C2.00144 2.70854 2.00144 2.39196 2.1967 2.1967ZM0.5 7C0.223858 7 0 7.22386 0 7.5C0 7.77614 0.223858 8 0.5 8H2.5C2.77614 8 3 7.77614 3 7.5C3 7.22386 2.77614 7 2.5 7H0.5ZM2.1967 12.8033C2.00144 12.608 2.00144 12.2915 2.1967 12.0962L3.61091 10.682C3.80617 10.4867 4.12276 10.4867 4.31802 10.682C4.51328 10.8772 4.51328 11.1938 4.31802 11.3891L2.90381 12.8033C2.70854 12.9986 2.39196 12.9986 2.1967 12.8033ZM12.5 7C12.2239 7 12 7.22386 12 7.5C12 7.77614 12.2239 8 12.5 8H14.5C14.7761 8 15 7.77614 15 7.5C15 7.22386 14.7761 7 14.5 7H12.5ZM10.682 4.31802C10.4867 4.12276 10.4867 3.80617 10.682 3.61091L12.0962 2.1967C12.2915 2.00144 12.608 2.00144 12.8033 2.1967C12.9986 2.39196 12.9986 2.70854 12.8033 2.90381L11.3891 4.31802C11.1938 4.51328 10.8772 4.51328 10.682 4.31802ZM8 12.5C8 12.2239 7.77614 12 7.5 12C7.22386 12 7 12.2239 7 12.5V14.5C7 14.7761 7.22386 15 7.5 15C7.77614 15 8 14.7761 8 14.5V12.5ZM10.682 10.682C10.8772 10.4867 11.1938 10.4867 11.3891 10.682L12.8033 12.0962C12.9986 12.2915 12.9986 12.608 12.8033 12.8033C12.608 12.9986 12.2915 12.9986 12.0962 12.8033L10.682 11.3891C10.4867 11.1938 10.4867 10.8772 10.682 10.682ZM5.5 7.5C5.5 6.39543 6.39543 5.5 7.5 5.5C8.60457 5.5 9.5 6.39543 9.5 7.5C9.5 8.60457 8.60457 9.5 7.5 9.5C6.39543 9.5 5.5 8.60457 5.5 7.5ZM7.5 4.5C5.84315 4.5 4.5 5.84315 4.5 7.5C4.5 9.15685 5.84315 10.5 7.5 10.5C9.15685 10.5 10.5 9.15685 10.5 7.5C10.5 5.84315 9.15685 4.5 7.5 4.5Z", + ) +} + +pub fn moon(attrs: List(Attribute(msg))) -> Element(msg) { + icon( + attrs, + "M2.89998 0.499976C2.89998 0.279062 2.72089 0.0999756 2.49998 0.0999756C2.27906 0.0999756 2.09998 0.279062 2.09998 0.499976V1.09998H1.49998C1.27906 1.09998 1.09998 1.27906 1.09998 1.49998C1.09998 1.72089 1.27906 1.89998 1.49998 1.89998H2.09998V2.49998C2.09998 2.72089 2.27906 2.89998 2.49998 2.89998C2.72089 2.89998 2.89998 2.72089 2.89998 2.49998V1.89998H3.49998C3.72089 1.89998 3.89998 1.72089 3.89998 1.49998C3.89998 1.27906 3.72089 1.09998 3.49998 1.09998H2.89998V0.499976ZM5.89998 3.49998C5.89998 3.27906 5.72089 3.09998 5.49998 3.09998C5.27906 3.09998 5.09998 3.27906 5.09998 3.49998V4.09998H4.49998C4.27906 4.09998 4.09998 4.27906 4.09998 4.49998C4.09998 4.72089 4.27906 4.89998 4.49998 4.89998H5.09998V5.49998C5.09998 5.72089 5.27906 5.89998 5.49998 5.89998C5.72089 5.89998 5.89998 5.72089 5.89998 5.49998V4.89998H6.49998C6.72089 4.89998 6.89998 4.72089 6.89998 4.49998C6.89998 4.27906 6.72089 4.09998 6.49998 4.09998H5.89998V3.49998ZM1.89998 6.49998C1.89998 6.27906 1.72089 6.09998 1.49998 6.09998C1.27906 6.09998 1.09998 6.27906 1.09998 6.49998V7.09998H0.499976C0.279062 7.09998 0.0999756 7.27906 0.0999756 7.49998C0.0999756 7.72089 0.279062 7.89998 0.499976 7.89998H1.09998V8.49998C1.09998 8.72089 1.27906 8.89997 1.49998 8.89997C1.72089 8.89997 1.89998 8.72089 1.89998 8.49998V7.89998H2.49998C2.72089 7.89998 2.89998 7.72089 2.89998 7.49998C2.89998 7.27906 2.72089 7.09998 2.49998 7.09998H1.89998V6.49998ZM8.54406 0.98184L8.24618 0.941586C8.03275 0.917676 7.90692 1.1655 8.02936 1.34194C8.17013 1.54479 8.29981 1.75592 8.41754 1.97445C8.91878 2.90485 9.20322 3.96932 9.20322 5.10022C9.20322 8.37201 6.82247 11.0878 3.69887 11.6097C3.45736 11.65 3.20988 11.6772 2.96008 11.6906C2.74563 11.702 2.62729 11.9535 2.77721 12.1072C2.84551 12.1773 2.91535 12.2458 2.98667 12.3128L3.05883 12.3795L3.31883 12.6045L3.50684 12.7532L3.62796 12.8433L3.81491 12.9742L3.99079 13.089C4.11175 13.1651 4.23536 13.2375 4.36157 13.3059L4.62496 13.4412L4.88553 13.5607L5.18837 13.6828L5.43169 13.7686C5.56564 13.8128 5.70149 13.8529 5.83857 13.8885C5.94262 13.9155 6.04767 13.9401 6.15405 13.9622C6.27993 13.9883 6.40713 14.0109 6.53544 14.0298L6.85241 14.0685L7.11934 14.0892C7.24637 14.0965 7.37436 14.1002 7.50322 14.1002C11.1483 14.1002 14.1032 11.1453 14.1032 7.50023C14.1032 7.25044 14.0893 7.00389 14.0623 6.76131L14.0255 6.48407C13.991 6.26083 13.9453 6.04129 13.8891 5.82642C13.8213 5.56709 13.7382 5.31398 13.6409 5.06881L13.5279 4.80132L13.4507 4.63542L13.3766 4.48666C13.2178 4.17773 13.0353 3.88295 12.8312 3.60423L12.6782 3.40352L12.4793 3.16432L12.3157 2.98361L12.1961 2.85951L12.0355 2.70246L11.8134 2.50184L11.4925 2.24191L11.2483 2.06498L10.9562 1.87446L10.6346 1.68894L10.3073 1.52378L10.1938 1.47176L9.95488 1.3706L9.67791 1.2669L9.42566 1.1846L9.10075 1.09489L8.83599 1.03486L8.54406 0.98184ZM10.4032 5.30023C10.4032 4.27588 10.2002 3.29829 9.83244 2.40604C11.7623 3.28995 13.1032 5.23862 13.1032 7.50023C13.1032 10.593 10.596 13.1002 7.50322 13.1002C6.63646 13.1002 5.81597 12.9036 5.08355 12.5522C6.5419 12.0941 7.81081 11.2082 8.74322 10.0416C8.87963 10.2284 9.10028 10.3497 9.34928 10.3497C9.76349 10.3497 10.0993 10.0139 10.0993 9.59971C10.0993 9.24256 9.84965 8.94373 9.51535 8.86816C9.57741 8.75165 9.63653 8.63334 9.6926 8.51332C9.88358 8.63163 10.1088 8.69993 10.35 8.69993C11.0403 8.69993 11.6 8.14028 11.6 7.44993C11.6 6.75976 11.0406 6.20024 10.3505 6.19993C10.3853 5.90487 10.4032 5.60464 10.4032 5.30023Z", + ) +} diff --git a/docs/src/highlight.ffi.mjs b/docs/src/highlight.ffi.mjs new file mode 100644 index 0000000..a67dfe2 --- /dev/null +++ b/docs/src/highlight.ffi.mjs @@ -0,0 +1,151 @@ +import "highlight.js/styles/github.css"; + +let did_register_html = false; +let did_register_js = false; +let did_register_sh = false; +let did_register_gleam = false; + +export async function highlight_element(el, lang) { + if (el.classList.contains("hljs")) return; + + const { default: highlight } = await import("highlight.js/lib/core"); + + switch (lang) { + case !did_register_html && "html": + const { default: html } = await import("highlight.js/lib/languages/xml"); + highlight.registerLanguage("html", html); + did_register_html = true; + break; + + case !did_register_js && "javascript": + const { default: js } = await import( + "highlight.js/lib/languages/javascript" + ); + highlight.registerLanguage("javascript", js); + did_register_js = true; + break; + + case !did_register_sh && "shell": + const { default: sh } = await import("highlight.js/lib/languages/shell"); + highlight.registerLanguage("sh", sh); + highlight.registerLanguage("shell", sh); + did_register_sh = true; + break; + + case !did_register_gleam && "gleam": + highlight.registerLanguage("gleam", gleam); + did_register_gleam = true; + break; + } + + highlight.highlightElement(el); +} + +function gleam(hljs) { + const KEYWORDS = + "as assert case const fn if import let panic use opaque pub todo 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: "attribute", + begin: "@", + end: "\\(", + excludeEnd: true, + }, + { + 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, + ], + }; +} diff --git a/docs/src/markdown.ffi.mjs b/docs/src/markdown.ffi.mjs new file mode 100644 index 0000000..7267c82 --- /dev/null +++ b/docs/src/markdown.ffi.mjs @@ -0,0 +1,164 @@ +import { attribute } from "../lustre/lustre/attribute.mjs"; +import { element, text } from "../lustre/lustre/element.mjs"; +import { List, Empty, NonEmpty } from "./gleam.mjs"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { highlight_element } from "./highlight.ffi.mjs"; +import * as Markdown from "./app/ui/markdown.mjs"; + +const empty = new Empty(); +const singleton = (val) => new NonEmpty(val, empty); +const fold_into_list = (arr, f) => + arr.reduceRight((acc, val) => new NonEmpty(f(val), acc), empty); + +function compute_hash(str) { + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + let chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return `${~hash}`; +} + +function linkify(el) { + for (const [t, url] of Object.entries(links)) { + el.innerHTML = el.innerHTML.replace( + new RegExp(`\\b${t}\\b`, "g"), + `<a href="${url}" class="hover:underline">${t}</a>` + ); + } +} + +const base = import.meta.env.BASE_URL; +const stdlib = "https://hexdocs.pm/gleam_stdlib/gleam/"; +const links = { + App: `${base}api/lustre#app-type`, + Attribute: `${base}api/lustre/attribute#attribute-type`, + Bool: `${stdlib}bool.html`, + Decoder: `${stdlib}dynamic.html#Decoder`, + Dynamic: `${stdlib}dynamic.html#Dynamic`, + Effect: `${base}api/lustre/effect#effect-type`, + Element: `${base}api/lustre/element#element-type`, + Error: `${base}api/lustre#error-type`, + Float: `${stdlib}float.html`, + Int: `${stdlib}int.html`, + List: `${stdlib}list.html`, + Map: `${stdlib}map.html#Map`, + Option: `${stdlib}option.html#Option`, + Result: `${stdlib}result.html`, + String: `${stdlib}string.html`, + StringBuilder: `${stdlib}string_builder.html#StringBuilder`, +}; + +const cashe = new Map(); + +export function parse_markdown(md) { + window.requestAnimationFrame(() => { + const selector = `[data-hash]:not(.hljs)`; + + for (const code of document.querySelectorAll(selector)) { + highlight_element(code, code.dataset.lang).then(() => { + linkify(code); + }); + } + }); + + if (cashe.has(md)) return cashe.get(md); + + const ast = fromMarkdown(md); + const summary = []; + const content = fold_into_list( + ast.children, + function to_lustre_element(node) { + switch (node.type) { + case "code": + return Markdown.code(node.value, compute_hash(node.value), node.lang); + + case "emphasis": + return Markdown.emphasis( + fold_into_list(node.children, to_lustre_element) + ); + + case "heading": { + const [title, rest] = node.children[0].value.split("|"); + const tags = List.fromArray(rest ? rest.trim().split(" ") : []); + const id = + /^[A-Z]/.test(title.trim()) && + node.depth === 3 && + window.location.pathname.includes("/api/") + ? `${title + .toLowerCase() + .trim() + .replace(/\s/g, "-") + .replace(/[^a-zA-Z0-9-_]/g, "")}-type` + : `${title + .toLowerCase() + .trim() + .replace(/\s/g, "-") + .replace(/[^a-zA-Z0-9-_]/g, "")}`; + + if (node.depth > 1) { + summary.unshift( + element( + "a", + List.fromArray([ + attribute("href", `#${id}`), + attribute("class", "text-sm text-gray-400 no-underline"), + attribute("class", "hover:text-gray-700 hover:underline"), + attribute( + "class", + node.depth === 2 ? `mt-4 first:mt-0` : `ml-2` + ), + ]), + singleton(text(title.trim())) + ) + ); + } + + return Markdown.heading(node.depth, title.trim(), tags, id); + } + + case "inlineCode": + return Markdown.inline_code(node.value); + + case "link": + return Markdown.link( + node.url.startsWith("/") + ? import.meta.BASE_URL + node.url.slice(1) + : node.url, + fold_into_list(node.children, to_lustre_element) + ); + + case "list": + return Markdown.list( + !!node.ordered, + fold_into_list(node.children, to_lustre_element) + ); + + case "listItem": + return Markdown.list_item( + fold_into_list(node.children, to_lustre_element) + ); + + case "paragraph": + return Markdown.paragraph( + fold_into_list(node.children, to_lustre_element) + ); + + case "strong": + return Markdown.strong( + fold_into_list(node.children, to_lustre_element) + ); + + case "text": + return Markdown.text(node.value); + + default: + return Markdown.text(""); + } + } + ); + + cashe.set(md, [content, List.fromArray(summary)]); + return [content, List.fromArray(summary)]; +} diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index 24b6555..2f45b89 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -3,8 +3,17 @@ export default { theme: { extend: { fontFamily: { - sans: ["NTDapper"], + serif: ["Fraunces", "serif"], + sans: ["Inter", "sans-serif"], }, + typography: (theme) => ({ + lustre: { + css: { + "--tw-prose-pre-code": "var(--tw-prose-body)", + "--tw-prose-pre-bg": theme("colors.gray[50]"), + }, + }, + }), }, }, plugins: [require("@tailwindcss/typography")], diff --git a/docs/test/docs_test.gleam b/docs/test/docs_test.gleam deleted file mode 100644 index 3831e7a..0000000 --- a/docs/test/docs_test.gleam +++ /dev/null @@ -1,12 +0,0 @@ -import gleeunit -import gleeunit/should - -pub fn main() { - gleeunit.main() -} - -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - 1 - |> should.equal(1) -} diff --git a/docs/vite.config.js b/docs/vite.config.js index 555777e..bf6cad8 100644 --- a/docs/vite.config.js +++ b/docs/vite.config.js @@ -1,14 +1,48 @@ import { defineConfig } from "vite"; import { ghPages } from "vite-plugin-gh-pages"; +import { resolve } from "path"; import gleam from "vite-gleam"; +import { execSync } from "child_process"; + +const moveForDeployment = { + name: "vite-plugin-move-for-deployment", + apply: "build", + closeBundle() { + const temp = resolve(__dirname, ".temp"); + const dist = resolve(__dirname, "dist"); + + execSync(`mkdir ${temp}`); + execSync(`mv ${dist}/* ${temp}`); + execSync(`rm -rf ${dist}`); + execSync(`mkdir ${dist} && mkdir ${dist}/lustre`); + execSync(`mv ${temp}/* ${dist}/lustre`); + execSync(`rm -rf ${temp}`); + execSync(`mv ${dist}/lustre/404.html ${dist}/404.html`); + execSync(`mv ${dist}/lustre/CNAME ${dist}/CNAME`); + }, +}; export default defineConfig(({ command }) => ({ - base: command === "build" ? "/gleam-lustre/" : "/", + base: command === "build" ? "/lustre/" : "/", + server: { + host: "0.0.0.0", + }, plugins: [ gleam(), + command === "build" && moveForDeployment, ghPages({ branch: "docs", message: "🚀 Deploy to gh-pages.", }), ], + build: { + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + 404: resolve(__dirname, "404.html"), + }, + }, + }, })); |