├── .cargo └── config.toml ├── static ├── clock.png ├── todos.png ├── counter.png ├── clock.css ├── counter.css └── todos.css ├── examples ├── list.html ├── clock.html ├── todos.html ├── counter.html ├── README.md ├── counter.rs ├── clock.rs └── todos.rs ├── .gitignore ├── rustfmt.toml ├── web ├── .gitignore ├── package.json ├── rollup.config.js ├── main.js └── package-lock.json ├── LICENSE ├── src ├── csrf.rs ├── rendered │ ├── dynamic.rs │ ├── strip.rs │ ├── diff.rs │ └── builder.rs ├── manager.rs ├── template.rs ├── maud.rs ├── event_handler.rs ├── live_view.rs ├── lib.rs ├── rendered.rs ├── handler.rs └── socket.rs ├── Cargo.toml ├── README.md └── tests └── diff.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-wasi" 3 | 4 | [target.wasm32-wasi] 5 | runner = "lunatic" 6 | -------------------------------------------------------------------------------- /static/clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/clock.png -------------------------------------------------------------------------------- /static/todos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/todos.png -------------------------------------------------------------------------------- /examples/list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /static/counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/counter.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | persistence 8 | 9 | /.vscode 10 | Cargo.lock 11 | -------------------------------------------------------------------------------- /examples/clock.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | LiveView Clock 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/todos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | LiveView Todos 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/counter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | LiveView Counter 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Stable 2 | newline_style = "Unix" 3 | use_field_init_shorthand = true 4 | 5 | # Unstable 6 | unstable_features = true 7 | version = "One" 8 | group_imports = "StdExternalCrate" 9 | imports_granularity = "Module" 10 | wrap_comments = true 11 | -------------------------------------------------------------------------------- /static/clock.css: -------------------------------------------------------------------------------- 1 | body { 2 | text-align: center; 3 | font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | min-height: 100vh; 9 | margin: 0; 10 | } 11 | 12 | h2 { 13 | margin-top: 0; 14 | font-size: 24px; 15 | } 16 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # LiveView Examples 2 | 3 | Examples can be run with `cargo run --example `. 4 | 5 | ### Clock 6 | 7 | Clock 8 | 9 | ### Counter 10 | 11 | Counter 12 | 13 | ### Todos 14 | 15 | Todos 16 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "build": "rollup -c" 7 | }, 8 | "devDependencies": { 9 | "@rollup/plugin-commonjs": "^22.0.2", 10 | "@rollup/plugin-node-resolve": "^14.0.1", 11 | "@rollup/plugin-terser": "^0.1.0", 12 | "rollup": "^2.79.0", 13 | "rollup-plugin-inject-process-env": "^1.3.1" 14 | }, 15 | "dependencies": { 16 | "phoenix": "^1.6.11", 17 | "phoenix_live_view": "^0.17.11", 18 | "topbar": "^1.0.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /static/counter.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | } 4 | 5 | body > div { 6 | text-align: center; 7 | padding: 64px 0; 8 | } 9 | 10 | button { 11 | cursor: pointer; 12 | background: #1890ff; 13 | border: 1px solid #1890ff; 14 | color: #fff; 15 | border-radius: 2px; 16 | margin: 8px; 17 | font-size: 18px; 18 | padding: 8px 16px; 19 | transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); 20 | } 21 | 22 | button:hover { 23 | background-color: #40a9ff; 24 | border-color: #40a9ff; 25 | } 26 | 27 | button:active { 28 | background-color: #096dd9; 29 | border-color: #096dd9; 30 | } 31 | 32 | p { 33 | font-size: 22px; 34 | } 35 | -------------------------------------------------------------------------------- /web/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import terser from "@rollup/plugin-terser"; 4 | import injectProcessEnv from "rollup-plugin-inject-process-env"; 5 | 6 | function createExport(fileName, nodeEnv, minify) { 7 | return { 8 | input: "main.js", 9 | output: { 10 | file: `../dist/${fileName}`, 11 | format: "umd", 12 | }, 13 | plugins: [ 14 | resolve(), // so Rollup can find `ms` 15 | commonjs(), // so Rollup can convert `ms` to an ES module 16 | injectProcessEnv({ 17 | NODE_ENV: nodeEnv, 18 | }), 19 | ...(minify ? [terser()] : []), 20 | ], 21 | }; 22 | } 23 | 24 | export default [ 25 | // debug 26 | createExport("liveview-debug.js", "development", false), 27 | // release 28 | createExport("liveview-release.js", "production", true), 29 | ]; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lunatic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/csrf.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose, Engine}; 2 | use rand::{thread_rng, Rng}; 3 | 4 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 5 | pub struct CsrfToken { 6 | pub masked: String, 7 | pub unmasked: String, 8 | } 9 | 10 | impl CsrfToken { 11 | /// Generates a crypto secure random key url-safe base64 encoded. 12 | pub fn generate() -> Self { 13 | let unmasked = generate_token(); 14 | let masked = mask(&unmasked); 15 | 16 | CsrfToken { masked, unmasked } 17 | } 18 | } 19 | 20 | /// Generates a crypto secure random key url-safe base64 encoded. 21 | fn generate_token() -> String { 22 | let mut rng = thread_rng(); 23 | let key: [u8; 18] = rng.gen(); 24 | general_purpose::URL_SAFE.encode(key) 25 | } 26 | 27 | /// Masks a token by xor'ing with another generated token. 28 | fn mask(token: &str) -> String { 29 | let mask = generate_token(); 30 | let xor: Vec<_> = token 31 | .as_bytes() 32 | .iter() 33 | .zip(mask.as_bytes().iter()) 34 | .map(|(x1, x2)| x1 & x2) 35 | .collect(); 36 | let mut masked = general_purpose::URL_SAFE.encode(xor); 37 | masked.push_str(&mask); 38 | masked 39 | } 40 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "submillisecond-live-view" 3 | version = "0.4.1" 4 | edition = "2021" 5 | license = "MIT/Apache-2.0" 6 | description = "A LiveView implementation for the submillisecond web framework." 7 | repository = "https://github.com/lunatic-solutions/submillisecond-live-view" 8 | 9 | [dependencies] 10 | base64 = "0.21" 11 | const-random = "0.1" 12 | enumflags2 = "0.7" 13 | hmac = { version = "0.12.1", features = ["std"] } 14 | itertools = "0.10" 15 | jwt = "0.16.0" 16 | lunatic = { version = "0.13", features = ["json_serializer"] } 17 | lunatic-log = "0.4" 18 | maud-live-view = "0.24.3" 19 | nipper = "0.1" 20 | pretty_assertions = "1.3" 21 | rand = "0.8" 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | serde_qs = "0.12" 25 | sha2 = "0.10.6" 26 | slotmap = "1.0" 27 | submillisecond = { version = "0.4.0", features = ["cookies", "websocket"] } 28 | thiserror = "1.0" 29 | tungstenite = "0.19" 30 | 31 | [dev-dependencies] 32 | chrono = { version = "0.4", features = ["serde"] } 33 | chrono-tz = { version = "0.8", features = ["serde"] } 34 | uuid = { version = "1.3", features = ["serde", "v4"] } 35 | 36 | [features] 37 | default = ["liveview_js"] 38 | liveview_js = [] 39 | 40 | [package.metadata.docs.rs] 41 | targets = ["wasm32-wasi"] 42 | -------------------------------------------------------------------------------- /web/main.js: -------------------------------------------------------------------------------- 1 | // Establish Phoenix Socket and LiveView configuration. 2 | import { Socket } from "phoenix"; 3 | import { LiveSocket } from "phoenix_live_view"; 4 | import topbar from "topbar"; 5 | 6 | const csrfToken = document 7 | .querySelector("meta[name='csrf-token']") 8 | .getAttribute("content"); 9 | 10 | class SocketWrapper { 11 | constructor(endPoint, opts) { 12 | const socket = new Socket(endPoint, opts); 13 | socket.endPoint = endPoint; 14 | return socket; 15 | } 16 | } 17 | 18 | const liveSocket = new LiveSocket("/", SocketWrapper, { 19 | params: { _csrf_token: csrfToken }, 20 | metadata: { 21 | click: (e, t) => ({ 22 | detail: e.detail, 23 | }), 24 | }, 25 | }); 26 | 27 | // Show progress bar on live navigation and form submits 28 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); 29 | window.addEventListener("phx:page-loading-start", (info) => topbar.show()); 30 | window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); 31 | 32 | // connect if there are any LiveViews on the page 33 | liveSocket.connect(); 34 | 35 | // expose liveSocket on window for web console debug logs and latency simulation: 36 | if (process.env.NODE_ENV !== "production") { 37 | liveSocket.enableDebug(); 38 | } 39 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 40 | // >> liveSocket.disableLatencySim() 41 | 42 | window.liveSocket = liveSocket; 43 | -------------------------------------------------------------------------------- /examples/counter.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use submillisecond::{router, static_router, Application}; 3 | use submillisecond_live_view::prelude::*; 4 | 5 | fn main() -> std::io::Result<()> { 6 | Application::new(router! { 7 | GET "/" => Counter::handler("examples/counter.html", "#app") 8 | "/static" => static_router!("./static") 9 | }) 10 | .serve("127.0.0.1:3000") 11 | } 12 | 13 | #[derive(Clone, Serialize, Deserialize)] 14 | struct Counter { 15 | count: i32, 16 | } 17 | 18 | impl LiveView for Counter { 19 | type Events = (Increment, Decrement); 20 | 21 | fn mount(_uri: Uri, _socket: Option) -> Self { 22 | Counter { count: 0 } 23 | } 24 | 25 | fn render(&self) -> Rendered { 26 | html! { 27 | button @click=(Increment) { "Increment" } 28 | button @click=(Decrement) { "Decrement" } 29 | p { "Count is " (self.count) } 30 | @if self.count >= 5 { 31 | p { "Count is high!" } 32 | } 33 | } 34 | } 35 | } 36 | 37 | #[derive(Serialize, Deserialize)] 38 | struct Increment {} 39 | 40 | impl LiveViewEvent for Counter { 41 | fn handle(state: &mut Self, _event: Increment) { 42 | state.count += 1; 43 | } 44 | } 45 | 46 | #[derive(Serialize, Deserialize)] 47 | struct Decrement {} 48 | 49 | impl LiveViewEvent for Counter { 50 | fn handle(state: &mut Self, _event: Decrement) { 51 | state.count -= 1; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/rendered/dynamic.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, ops}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 6 | pub(crate) enum Dynamics { 7 | Items(DynamicItems), 8 | List(DynamicList), 9 | } 10 | 11 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 12 | pub(crate) struct DynamicItems(pub Vec>); 13 | 14 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 15 | pub(crate) struct DynamicList(pub Vec>>); 16 | 17 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 18 | pub(crate) enum Dynamic { 19 | String(String), 20 | Nested(N), 21 | } 22 | 23 | impl ops::Deref for DynamicItems { 24 | type Target = Vec>; 25 | 26 | fn deref(&self) -> &Self::Target { 27 | &self.0 28 | } 29 | } 30 | 31 | impl ops::DerefMut for DynamicItems { 32 | fn deref_mut(&mut self) -> &mut Self::Target { 33 | &mut self.0 34 | } 35 | } 36 | 37 | impl ops::Deref for DynamicList { 38 | type Target = Vec>>; 39 | 40 | fn deref(&self) -> &Self::Target { 41 | &self.0 42 | } 43 | } 44 | 45 | impl ops::DerefMut for DynamicList { 46 | fn deref_mut(&mut self) -> &mut Self::Target { 47 | &mut self.0 48 | } 49 | } 50 | 51 | impl fmt::Display for Dynamic 52 | where 53 | N: fmt::Display, 54 | { 55 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 56 | match self { 57 | Dynamic::String(s) => { 58 | write!(f, "{s}") 59 | } 60 | Dynamic::Nested(n) => { 61 | write!(f, "{n}") 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/manager.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | use submillisecond::response::Response; 6 | use submillisecond::RequestContext; 7 | 8 | use crate::socket::{Event, JoinEvent, Socket}; 9 | use crate::LiveView; 10 | 11 | /// Handles requests and events. 12 | pub(crate) trait LiveViewManager 13 | where 14 | Self: Sized, 15 | T: LiveView, 16 | { 17 | type State: Serialize + for<'de> Deserialize<'de>; 18 | // type Reply: Serialize; 19 | type Error: fmt::Display; 20 | 21 | /// Handle an initial stateless request. 22 | fn handle_request(&self, req: RequestContext) -> Response; 23 | 24 | /// Handle a join event returning state and a reply. 25 | fn handle_join( 26 | &self, 27 | socket: Socket, 28 | event: JoinEvent, 29 | ) -> LiveViewManagerResult, Self::Error>; 30 | 31 | /// Handle an event. 32 | fn handle_event( 33 | &self, 34 | event: Event, 35 | state: &mut Self::State, 36 | live_view: &T, 37 | ) -> LiveViewManagerResult, Self::Error>; 38 | } 39 | 40 | /// Live view socket result for returning a response with a recoverable error, 41 | /// or fatal error. 42 | /// 43 | /// If fatal error is returned, the websocket connection is closed. 44 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 45 | pub(crate) enum LiveViewManagerResult { 46 | Ok(T), 47 | Error(E), 48 | FatalError(E), 49 | } 50 | 51 | pub(crate) struct Join { 52 | pub(crate) live_view: L, 53 | pub(crate) state: S, 54 | pub(crate) reply: R, 55 | } 56 | 57 | impl LiveViewManagerResult { 58 | pub(crate) fn into_result(self) -> Result { 59 | match self { 60 | LiveViewManagerResult::Ok(value) => Ok(value), 61 | LiveViewManagerResult::Error(err) | LiveViewManagerResult::FatalError(err) => Err(err), 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Submillisecond LiveView 2 | 3 | A LiveView implementation for the [submillisecond] web framework built with [lunatic]. 4 | 5 | # What is LiveView? 6 | 7 | LiveView provides rich, real-time user experiences with server-rendered HTML. 8 | 9 | The LiveView programming model is declarative: instead of saying "once event X happens, change Y on the page", 10 | events in LiveView are regular messages which may cause changes to its state. Once the state changes, 11 | LiveView will re-render the relevant parts of its HTML template and push it to the browser, 12 | which updates itself in the most efficient manner. 13 | This means developers write LiveView templates as any other server-rendered HTML and LiveView does the hard work 14 | of tracking changes and sending the relevant diffs to the browser. 15 | 16 | It was made popular by the [Phoenix] webframework for Elixir. 17 | 18 | [phoenix]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html 19 | 20 | # Prerequisites 21 | 22 | [Lunatic runtime] is required, along with the wasm32-wasi target. 23 | 24 | ```bash 25 | cargo install lunatic-runtime 26 | rustup target add wasm32-wasi 27 | ``` 28 | 29 | It is also recommended to add a `.cargo/config.toml` file with the build target and runner configured. 30 | 31 | ```toml 32 | # .cargo/config.toml 33 | 34 | [build] 35 | target = "wasm32-wasi" 36 | 37 | [target.wasm32-wasi] 38 | runner = "lunatic" 39 | ``` 40 | 41 | [lunatic runtime]: https://github.com/lunatic-solutions/lunatic-rs#setup 42 | 43 | # Code example 44 | 45 | ```rust 46 | use serde::{Deserialize, Serialize}; 47 | use submillisecond::{router, static_router, Application}; 48 | use submillisecond_live_view::prelude::*; 49 | 50 | fn main() -> std::io::Result<()> { 51 | Application::new(router! { 52 | "/" => Counter::handler("index.html", "#app") 53 | "/static" => static_router!("./static") 54 | }) 55 | .serve("127.0.0.1:3000") 56 | } 57 | 58 | #[derive(Clone, Serialize, Deserialize)] 59 | struct Counter { 60 | count: i32, 61 | } 62 | 63 | impl LiveView for Counter { 64 | type Events = (Increment, Decrement); 65 | 66 | fn mount(_uri: Uri, _socket: Option<&mut Socket>) -> Self { 67 | Counter { count: 0 } 68 | } 69 | 70 | fn render(&self) -> Rendered { 71 | html! { 72 | button @click=(Increment) { "Increment" } 73 | button @click=(Decrement) { "Decrement" } 74 | p { "Count is " (self.count) } 75 | } 76 | } 77 | } 78 | 79 | #[derive(Deserialize)] 80 | struct Increment {} 81 | 82 | impl LiveViewEvent for Counter { 83 | fn handle(state: &mut Self, _event: Increment) { 84 | state.count += 1; 85 | } 86 | } 87 | 88 | #[derive(Deserialize)] 89 | struct Decrement {} 90 | 91 | impl LiveViewEvent for Counter { 92 | fn handle(state: &mut Self, _event: Decrement) { 93 | state.count -= 1; 94 | } 95 | } 96 | ``` 97 | 98 | ## Running examples 99 | 100 | Clone the repository 101 | 102 | ```bash 103 | git clone git@github.com:lunatic-solutions/submillisecond-live-view.git 104 | cd submillisecond-live-view 105 | ``` 106 | 107 | Run an example 108 | 109 | ```bash 110 | cargo run --example clock 111 | ``` 112 | 113 | # License 114 | 115 | Licensed under either of 116 | 117 | - Apache License, Version 2.0, (http://www.apache.org/licenses/LICENSE-2.0) 118 | - MIT license (http://opensource.org/licenses/MIT) 119 | 120 | at your option. 121 | 122 | [lunatic]: https://lunatic.solutions 123 | [submillisecond]: https://github.com/lunatic-solutions/submillisecond 124 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io}; 2 | 3 | use hmac::{Hmac, Mac}; 4 | use jwt::SignWithKey; 5 | use lunatic::abstract_process; 6 | use lunatic::ap::{AbstractProcess, Config, ProcessRef}; 7 | use nipper::Document; 8 | use rand::distributions::Alphanumeric; 9 | use rand::Rng; 10 | use sha2::Sha256; 11 | 12 | use crate::csrf::CsrfToken; 13 | use crate::maud::{secret, Session}; 14 | 15 | const TEMPLATE_PROCESS_ID: &str = "e6cdcfeb-8552-4de2-8e8b-484724380248"; 16 | 17 | #[cfg(all(debug_assertions, feature = "liveview_js"))] 18 | const LIVEVIEW_JS: &str = include_str!("../dist/liveview-debug.js"); 19 | 20 | #[cfg(all(not(debug_assertions), feature = "liveview_js"))] 21 | const LIVEVIEW_JS: &str = include_str!("../dist/liveview-release.js"); 22 | 23 | const HTML_SEPARATOR: &str = ""; 24 | 25 | pub struct TemplateProcess { 26 | html_parts: [String; 3], 27 | } 28 | 29 | #[abstract_process(visibility = pub)] 30 | impl TemplateProcess { 31 | #[init] 32 | fn init(_: Config, (html, selector): (String, String)) -> Result { 33 | let document = Document::from(&html.replace(0x0 as char, "")); 34 | #[cfg(feature = "liveview_js")] 35 | document.select("head").append_html(format!( 36 | r#"{HTML_SEPARATOR}"#, 37 | )); 38 | #[cfg(not(feature = "liveview_js"))] 39 | document 40 | .select("head") 41 | .append_html(format!(r#"{HTML_SEPARATOR}"#,)); 42 | let mut selection = document.select(&selector); 43 | if !selection.exists() { 44 | panic!("selector '{selector}' does not exist"); 45 | } 46 | selection.append_html(HTML_SEPARATOR); 47 | let html_parts = document 48 | .html() 49 | .to_string() 50 | .splitn(3, HTML_SEPARATOR) 51 | .map(|s| s.to_string()) 52 | .collect::>() 53 | .try_into() 54 | .unwrap(); 55 | Ok(TemplateProcess { html_parts }) 56 | } 57 | 58 | #[handle_request] 59 | fn render(&self, content: String) -> String { 60 | let mut html_parts = self.html_parts.clone(); 61 | 62 | let mut rng = rand::thread_rng(); 63 | let id: String = (&mut rng) 64 | .sample_iter(Alphanumeric) 65 | .take(16) 66 | .map(char::from) 67 | .collect(); 68 | 69 | let key: Hmac = Hmac::new_from_slice(&secret()).expect("unable to encode secret"); 70 | let csrf_token = CsrfToken::generate().masked; 71 | let session = Session { 72 | csrf_token: csrf_token.clone(), 73 | }; 74 | let session_str = session.sign_with_key(&key).expect("failed to sign session"); 75 | 76 | html_parts[0].push_str(&format!( 77 | r#""# 78 | )); 79 | 80 | html_parts[1].push_str(&format!( 81 | r#"
{content}
"# 82 | )); 83 | 84 | html_parts.into_iter().collect() 85 | } 86 | 87 | pub fn start(path: &str, selector: &str) -> io::Result> { 88 | let name = Self::process_name(path, selector); 89 | let template = fs::read_to_string(path)?; 90 | let process = Self::start_as(&name, (template, selector.to_string())).unwrap(); 91 | process.link(); 92 | Ok(process) 93 | } 94 | 95 | pub fn lookup(path: &str, selector: &str) -> Option> { 96 | let name = Self::process_name(path, selector); 97 | ProcessRef::lookup(&name) 98 | } 99 | 100 | fn process_name(path: &str, selector: &str) -> String { 101 | format!("{TEMPLATE_PROCESS_ID}-{path}-{selector}") 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/rendered/strip.rs: -------------------------------------------------------------------------------- 1 | use enumflags2::{bitflags, BitFlags}; 2 | use serde_json::Value; 3 | 4 | /// Specifies the type of strip operation to perform using Bitwise OR eg. 5 | /// Strip::Nulls | Strip::Empties 6 | #[bitflags] 7 | #[repr(u8)] 8 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 9 | pub enum Strip { 10 | Nulls, 11 | Empties, 12 | } 13 | 14 | /// Strips the provided value of specified Strip enum type. 15 | /// 16 | /// Note: This does NOT remove nulls inside arrays unless ALL are null and the 17 | /// [Strip] `Null` | `Empties` options are set due to the potential for 18 | /// re-ordering indexes where each may have a specific meaning. 19 | /// ```rust 20 | /// use json_plus::{strip, Strip}; 21 | /// use serde_json::json; 22 | /// 23 | /// fn main() -> Result<(), Box> { 24 | /// let base = json!({"key":"old value", "null":null, "empty":[]}); 25 | /// 26 | /// let stripped = strip(Strip::Nulls | Strip::Empties, base).unwrap(); 27 | /// println!("{}", stripped.to_string()); 28 | /// Ok(()) 29 | /// } 30 | /// ``` 31 | #[inline] 32 | pub fn strip(mask: BitFlags, mut value: Value) -> Option { 33 | match strip_mut_inner(mask, &mut value) { 34 | false => None, 35 | true => Some(value), 36 | } 37 | } 38 | 39 | fn strip_mut_inner(mask: BitFlags, value: &mut Value) -> bool { 40 | match value { 41 | Value::Null => !mask.intersects(Strip::Nulls), 42 | Value::Object(ref mut o) => { 43 | o.retain(|_, v| strip_mut_inner(mask, v)); 44 | !(o.is_empty() && mask.intersects(Strip::Empties)) 45 | } 46 | Value::Array(a) => { 47 | // We do NOT remove nulls inside arrays unless ALL are null and null | empties 48 | // is set due to the potential for re-ordering indexes where each 49 | // may have a specific meaning. 50 | let mut null_count = 0; 51 | for value in a.iter_mut() { 52 | if !strip_mut_inner(mask, value) { 53 | *value = Value::Null; 54 | null_count += 1; 55 | } 56 | } 57 | if null_count == a.len() { 58 | a.clear(); 59 | } 60 | 61 | !(a.is_empty() && mask.intersects(Strip::Empties)) 62 | } 63 | _ => true, 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use serde_json::json; 70 | 71 | use super::*; 72 | 73 | #[test] 74 | fn strip_it() { 75 | assert_eq!(strip(Strip::Nulls.into(), json!(null)), None); 76 | assert_eq!(strip(Strip::Nulls | Strip::Empties, json!(null)), None); 77 | assert_eq!(strip(Strip::Nulls.into(), json!({})), Some(json!({}))); 78 | assert_eq!(strip(Strip::Nulls | Strip::Empties, json!({})), None); 79 | assert_eq!( 80 | strip( 81 | Strip::Nulls | Strip::Empties, 82 | json!({"key":{"value":"value", "null":null}, "arr":[null]}) 83 | ), 84 | Some(json!({"key":{"value":"value"}})) 85 | ); 86 | assert_eq!( 87 | strip( 88 | Strip::Nulls | Strip::Empties, 89 | json!({"key":{"value":"value", "null":null}, "arr":[null, 1]}) 90 | ), 91 | Some(json!({"key":{"value":"value"}, "arr":[null, 1]})) 92 | ); 93 | assert_eq!( 94 | strip( 95 | Strip::Nulls | Strip::Empties, 96 | json!({"key":{"value":"value", "null":null}, "arr":[]}) 97 | ), 98 | Some(json!({"key":{"value":"value"}})) 99 | ); 100 | assert_eq!( 101 | strip( 102 | Strip::Empties.into(), 103 | json!({"key":{"value":null, "null":null}, "arr":[null], "empty":[]}) 104 | ), 105 | Some(json!({"key":{"value":null, "null":null}, "arr":[null]})) 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /examples/clock.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use chrono::Utc; 4 | use lunatic::{Mailbox, MailboxError, Process}; 5 | use serde::{Deserialize, Serialize}; 6 | use submillisecond::{router, static_router, Application}; 7 | use submillisecond_live_view::prelude::*; 8 | 9 | fn main() -> std::io::Result<()> { 10 | Application::new(router! { 11 | GET "/" => Clock::handler("examples/clock.html", "#app") 12 | "/static" => static_router!("./static") 13 | }) 14 | .serve("127.0.0.1:3000") 15 | } 16 | 17 | #[derive(Clone, Debug, Serialize, Deserialize)] 18 | struct Clock { 19 | socket: Option, 20 | tick_frequency: u64, 21 | ticker: Option>, 22 | time: String, 23 | timezone: chrono_tz::Tz, 24 | } 25 | 26 | impl LiveView for Clock { 27 | type Events = (Tick, ChangeTimezone, ChangeTickFrequency); 28 | 29 | fn mount(_uri: Uri, socket: Option) -> Self { 30 | let ticker = if let Some(socket) = socket.clone() { 31 | let ticker = Process::spawn_link(socket, |mut socket, mailbox: Mailbox| { 32 | let mut update_frequency = 500; 33 | loop { 34 | match mailbox.receive_timeout(Duration::from_millis(update_frequency)) { 35 | Ok(ms) => { 36 | update_frequency = ms; 37 | } 38 | Err(MailboxError::TimedOut) => { 39 | socket.send_event(Tick {}).unwrap(); 40 | } 41 | err => panic!("{err:?}"), 42 | } 43 | } 44 | }); 45 | // TODO: Use this code when is merged and published. 46 | // let ticker = spawn_link!(|socket, mailbox: Mailbox| {}); 47 | Some(ticker) 48 | } else { 49 | None 50 | }; 51 | 52 | Clock { 53 | socket, 54 | tick_frequency: 500, 55 | ticker, 56 | time: Utc::now() 57 | .with_timezone(&chrono_tz::UTC) 58 | .format("%A, %H:%M:%S%.3f") 59 | .to_string(), 60 | timezone: chrono_tz::UTC, 61 | } 62 | } 63 | 64 | fn render(&self) -> Rendered { 65 | let tzs = chrono_tz::TZ_VARIANTS.iter(); 66 | 67 | html! { 68 | h2 { (self.time) } 69 | form { 70 | select name="timezone" @change=(ChangeTimezone) { 71 | @for tz in tzs { 72 | @let selected = if tz == &self.timezone { Some("selected") } else { None }; 73 | option 74 | value=(tz.name()) 75 | selected=[selected] 76 | { 77 | (tz.name()) 78 | } 79 | } 80 | } 81 | br {} 82 | input 83 | name="tick_frequency" 84 | type="range" 85 | min="100" max="1000" 86 | value=(self.tick_frequency) 87 | phx-throttle="500" 88 | @change=(ChangeTickFrequency); 89 | br {} 90 | span { (format!("{}ms", self.tick_frequency)) } 91 | } 92 | } 93 | } 94 | } 95 | 96 | #[derive(Serialize, Deserialize)] 97 | struct Tick {} 98 | 99 | impl LiveViewEvent for Clock { 100 | fn handle(state: &mut Self, _event: Tick) { 101 | state.time = Utc::now() 102 | .with_timezone(&state.timezone) 103 | .format("%A, %H:%M:%S%.3f") 104 | .to_string(); 105 | } 106 | } 107 | 108 | #[derive(Debug, Serialize, Deserialize)] 109 | struct ChangeTimezone { 110 | timezone: String, 111 | } 112 | 113 | impl LiveViewEvent for Clock { 114 | fn handle(state: &mut Self, ChangeTimezone { timezone }: ChangeTimezone) { 115 | state.timezone = timezone.parse().unwrap(); 116 | state.socket.as_mut().unwrap().spawn_send_event(Tick {}); 117 | } 118 | } 119 | 120 | #[derive(Debug, Serialize, Deserialize)] 121 | struct ChangeTickFrequency { 122 | tick_frequency: u64, 123 | } 124 | 125 | impl LiveViewEvent for Clock { 126 | fn handle(state: &mut Self, ChangeTickFrequency { tick_frequency }: ChangeTickFrequency) { 127 | state.tick_frequency = tick_frequency; 128 | state.ticker.as_ref().unwrap().send(tick_frequency); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/maud.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::env; 3 | use std::marker::PhantomData; 4 | 5 | pub use ::maud_live_view::*; 6 | use hmac::{Hmac, Mac}; 7 | use jwt::VerifyWithKey; 8 | use lunatic::ap::ProcessRef; 9 | use lunatic_log::error; 10 | use serde::{Deserialize, Serialize}; 11 | use serde_json::Value; 12 | use sha2::Sha256; 13 | use submillisecond::http::Uri; 14 | use submillisecond::response::Response; 15 | use submillisecond::RequestContext; 16 | use thiserror::Error; 17 | 18 | use crate::manager::{Join, LiveViewManager, LiveViewManagerResult}; 19 | use crate::rendered::{IntoJson, Rendered}; 20 | use crate::socket::{Event, JoinEvent, Socket}; 21 | use crate::template::{TemplateProcess, TemplateProcessRequests}; 22 | use crate::LiveView; 23 | 24 | #[derive(Serialize, Deserialize)] 25 | #[serde(bound = "")] 26 | pub struct LiveViewMaud { 27 | phantom: PhantomData, 28 | template_process: ProcessRef, 29 | } 30 | 31 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 32 | pub(crate) struct Session { 33 | pub(crate) csrf_token: String, 34 | } 35 | 36 | #[derive(Clone, Copy, Debug, Error, Serialize, Deserialize)] 37 | pub(crate) enum LiveViewMaudError { 38 | #[error("invalid csrf token")] 39 | InvalidCsrfToken, 40 | #[error("invalid url")] 41 | InvalidUrl, 42 | #[error("missing url")] 43 | MissingUrl, 44 | } 45 | 46 | impl LiveViewMaud { 47 | pub(crate) fn new(template_process: ProcessRef) -> Self { 48 | LiveViewMaud { 49 | phantom: PhantomData, 50 | template_process, 51 | } 52 | } 53 | } 54 | 55 | impl Clone for LiveViewMaud { 56 | fn clone(&self) -> Self { 57 | Self { 58 | phantom: self.phantom, 59 | template_process: self.template_process.clone(), 60 | } 61 | } 62 | } 63 | 64 | impl LiveViewManager for LiveViewMaud 65 | where 66 | T: LiveView, 67 | { 68 | type State = Rendered; 69 | // type Reply = Value; 70 | type Error = LiveViewMaudError; 71 | 72 | fn handle_request(&self, req: RequestContext) -> Response { 73 | let content = T::mount(req.uri().clone(), None).render().to_string(); 74 | let html = self.template_process.render(content); 75 | 76 | Response::builder() 77 | .header("Content-Type", "text/html; charset=UTF-8") 78 | .body(html.into_bytes()) 79 | .unwrap() 80 | } 81 | 82 | fn handle_join( 83 | &self, 84 | socket: Socket, 85 | event: JoinEvent, 86 | ) -> LiveViewManagerResult, Self::Error> { 87 | let key: Hmac = Hmac::new_from_slice(&secret()).expect("unable to encode secret"); 88 | let session: Result = event.session.verify_with_key(&key); 89 | 90 | // Verify csrf token 91 | if !session 92 | .map(|session| session.csrf_token == event.params.csrf_token) 93 | .unwrap_or(false) 94 | { 95 | return LiveViewManagerResult::FatalError(LiveViewMaudError::InvalidCsrfToken); 96 | } 97 | 98 | macro_rules! tri_fatal { 99 | ($e: expr) => { 100 | match $e { 101 | Result::Ok(ok) => ok, 102 | Err(err) => { 103 | return LiveViewManagerResult::FatalError(err); 104 | } 105 | } 106 | }; 107 | } 108 | 109 | let uri: Uri = tri_fatal!(tri_fatal!(event.url().ok_or(LiveViewMaudError::MissingUrl)) 110 | .parse() 111 | .map_err(|_| LiveViewMaudError::InvalidUrl)); 112 | 113 | let live_view = T::mount(uri, Some(socket)); 114 | let state = live_view.render(); 115 | let reply = state.clone().into_json(); 116 | LiveViewManagerResult::Ok(Join { 117 | live_view, 118 | state, 119 | reply, 120 | }) 121 | } 122 | 123 | fn handle_event( 124 | &self, 125 | _event: Event, 126 | state: &mut Self::State, 127 | live_view: &T, 128 | ) -> LiveViewManagerResult, Self::Error> { 129 | let rendered = live_view.render(); 130 | let diff = state.clone().diff(rendered.clone()); // TODO: Remove these clones 131 | *state = rendered; 132 | 133 | LiveViewManagerResult::Ok(diff) 134 | } 135 | } 136 | 137 | #[cfg(debug_assertions)] 138 | const SECRET_DEFAULT: [u8; 32] = *b"liveview-debug-secret-csrf-token"; 139 | 140 | #[cfg(not(debug_assertions))] 141 | const SECRET_DEFAULT: [u8; 32] = const_random::const_random!([u8; 32]); 142 | 143 | pub(crate) fn secret() -> Cow<'static, [u8]> { 144 | match env::var("LIVE_VIEW_SECRET") { 145 | Ok(secret) => Cow::Owned(secret.into_bytes()), 146 | Err(_) => Cow::Borrowed(&SECRET_DEFAULT), 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/event_handler.rs: -------------------------------------------------------------------------------- 1 | use lunatic::serializer::Json; 2 | use lunatic::{Mailbox, Process, Tag}; 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | use thiserror::Error; 6 | 7 | use crate::manager::{Join, LiveViewManager}; 8 | use crate::socket::{Event, JoinEvent, RawSocket, Socket}; 9 | use crate::{EventList, LiveView}; 10 | 11 | #[derive(Clone, Debug, Error, Serialize, Deserialize)] 12 | pub enum EventHandlerError { 13 | #[error("deserialize event failed")] 14 | DeserializeEvent, 15 | #[error("serialize event failed")] 16 | SerializeEvent, 17 | #[error("manager error: {0}")] 18 | ManagerError(String), 19 | #[error("not mounted")] 20 | NotMounted, 21 | #[error("socket error: {0}")] 22 | SocketError(String), 23 | #[error("unknown event")] 24 | UnknownEvent, 25 | } 26 | 27 | #[derive(Clone, Debug, Serialize, Deserialize)] 28 | pub(crate) struct EventHandler { 29 | event_handler: Process, 30 | } 31 | 32 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 33 | enum EventHandlerMessage { 34 | HandleJoin( 35 | Process, Json>, 36 | Tag, 37 | JoinEvent, 38 | ), 39 | HandleEvent( 40 | Process, EventHandlerError>, Json>, 41 | Tag, 42 | Event, 43 | ), 44 | } 45 | 46 | impl EventHandler { 47 | pub(crate) fn spawn(socket: RawSocket, manager: L) -> Self 48 | where 49 | L: LiveViewManager + Serialize + for<'de> Deserialize<'de>, 50 | T: LiveView, 51 | { 52 | let process = Process::spawn_link((socket, manager), event_handler); 53 | EventHandler { 54 | event_handler: process, 55 | } 56 | } 57 | 58 | pub(crate) fn handle_join(&self, join_event: JoinEvent) -> Result { 59 | let tag = Tag::new(); 60 | self.event_handler.send(EventHandlerMessage::HandleJoin( 61 | unsafe { Process::this() }, 62 | tag, 63 | join_event, 64 | )); 65 | let mailbox: Mailbox, Json> = unsafe { Mailbox::new() }; 66 | mailbox.tag_receive(&[tag]) 67 | } 68 | 69 | pub(crate) fn handle_event(&self, event: Event) -> Result, EventHandlerError> { 70 | let tag = Tag::new(); 71 | self.event_handler.send(EventHandlerMessage::HandleEvent( 72 | unsafe { Process::this() }, 73 | tag, 74 | event, 75 | )); 76 | let mailbox: Mailbox, EventHandlerError>, Json> = 77 | unsafe { Mailbox::new() }; 78 | mailbox.tag_receive(&[tag]) 79 | } 80 | } 81 | 82 | fn event_handler( 83 | (socket, manager): (RawSocket, L), 84 | mailbox: Mailbox, 85 | ) where 86 | L: LiveViewManager, 87 | T: LiveView, 88 | { 89 | let this: Process = mailbox.this(); 90 | let mut state = None; 91 | 92 | loop { 93 | let message = mailbox.receive(); 94 | match message { 95 | EventHandlerMessage::HandleJoin(parent, tag, join_event) => { 96 | let reply = match manager 97 | .handle_join( 98 | Socket { 99 | event_handler: EventHandler { 100 | event_handler: this, 101 | }, 102 | socket: socket.clone(), 103 | }, 104 | join_event, 105 | ) 106 | .into_result() 107 | { 108 | Ok(Join { 109 | live_view, 110 | state: new_state, 111 | reply, 112 | }) => { 113 | state = Some((live_view, new_state)); 114 | Ok(reply) 115 | } 116 | Err(err) => Err(EventHandlerError::ManagerError(err.to_string())), 117 | }; 118 | parent.tag_send(tag, reply); 119 | } 120 | EventHandlerMessage::HandleEvent(parent, tag, event) => { 121 | let reply = match &mut state { 122 | Some((live_view, state)) => { 123 | match >::handle_event(live_view, event.clone()) { 124 | Ok(handled) => { 125 | if !handled { 126 | Err(EventHandlerError::UnknownEvent) 127 | } else { 128 | manager 129 | .handle_event(event, state, live_view) 130 | .into_result() 131 | .map_err(|err| { 132 | EventHandlerError::ManagerError(err.to_string()) 133 | }) 134 | } 135 | } 136 | Err(_) => Err(EventHandlerError::DeserializeEvent), 137 | } 138 | } 139 | None => Err(EventHandlerError::NotMounted), 140 | }; 141 | parent.tag_send(tag, reply); 142 | } 143 | }; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/live_view.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use submillisecond::http::Uri; 3 | use thiserror::Error; 4 | 5 | use crate::rendered::Rendered; 6 | use crate::socket::{Event, Socket}; 7 | 8 | /// Html input checkbox value. 9 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 10 | pub enum CheckboxValue { 11 | /// Checked, 12 | #[serde(rename = "on")] 13 | Checked, 14 | /// Unchecked. 15 | #[serde(rename = "off")] 16 | Unchecked, 17 | } 18 | 19 | /// Deserialize event error. 20 | #[derive(Debug, Error)] 21 | pub enum DeserializeEventError { 22 | /// Deserialize form event error. 23 | #[error(transparent)] 24 | Form(#[from] serde_qs::Error), 25 | /// Deserialize event error. 26 | #[error(transparent)] 27 | Json(#[from] serde_json::Error), 28 | } 29 | 30 | /// A live view. 31 | pub trait LiveView: Sized { 32 | /// Events registered with this liveview. 33 | type Events: EventList; 34 | 35 | /// The LiveView entry-point. 36 | /// 37 | /// Mount is invoked twice: once to do the initial page load, and again to 38 | /// establish the live socket. 39 | fn mount(uri: Uri, socket: Option) -> Self; 40 | 41 | /// Renders a template. 42 | /// 43 | /// This callback is invoked whenever LiveView detects new content must be 44 | /// rendered and sent to the client. 45 | fn render(&self) -> Rendered; 46 | } 47 | 48 | /// Live view event handler. 49 | pub trait LiveViewEvent { 50 | /// Handler for the live view, typically used in the router. 51 | fn handle(state: &mut Self, event: E); 52 | } 53 | 54 | /// Event list is a trait to handle an incoming live view events and route them 55 | /// to the event handlers. 56 | pub trait EventList { 57 | /// Handles an event, returning a Result, with a bool indicating if the 58 | /// event was handled or not. 59 | fn handle_event(state: &mut T, event: Event) -> Result; 60 | } 61 | 62 | impl EventList for () { 63 | fn handle_event(_state: &mut T, _event: Event) -> Result { 64 | Ok(false) 65 | } 66 | } 67 | 68 | #[cfg(debug_assertions)] 69 | fn check_for_unit_struct() 70 | where 71 | T: for<'de> Deserialize<'de>, 72 | { 73 | if serde_json::from_str::("null").is_ok() { 74 | lunatic_log::error!( 75 | "unit structs are not supported as events. Change your event struct to be `{} {{}}`", 76 | std::any::type_name::() 77 | ); 78 | } 79 | } 80 | 81 | #[cfg(not(debug_assertions))] 82 | fn check_for_unit_struct() {} 83 | 84 | macro_rules! impl_event_list { 85 | ($( $t: ident ),*) => { 86 | impl EventList for ($( $t, )*) 87 | where 88 | $( 89 | T: LiveViewEvent<$t>, 90 | $t: for<'de> Deserialize<'de>, 91 | )* 92 | { 93 | fn handle_event(state: &mut T, event: Event) -> Result { 94 | $( 95 | if std::any::type_name::<$t>() == event.name { 96 | let value: $t = if event.ty == "form" { 97 | match event.value.as_str() { 98 | Some(value) => match serde_qs::from_str(value) { 99 | Ok(value) => value, 100 | Err(err) => { 101 | check_for_unit_struct::<$t>(); 102 | return Err(DeserializeEventError::Form(err)); 103 | } 104 | } 105 | None => { 106 | return Err(DeserializeEventError::Form(serde_qs::Error::Custom( 107 | "expected value to be string in form event".to_string(), 108 | ))); 109 | } 110 | } 111 | } else { 112 | match serde_json::from_value(event.value) { 113 | Ok(value) => value, 114 | Err(err) => { 115 | check_for_unit_struct::<$t>(); 116 | return Err(DeserializeEventError::Json(err)); 117 | } 118 | } 119 | }; 120 | T::handle(state, value); 121 | return Ok(true); 122 | } 123 | )* 124 | 125 | Ok(false) 126 | } 127 | } 128 | }; 129 | } 130 | 131 | impl_event_list!(A); 132 | impl_event_list!(A, B); 133 | impl_event_list!(A, B, C); 134 | impl_event_list!(A, B, C, D); 135 | impl_event_list!(A, B, C, D, E); 136 | impl_event_list!(A, B, C, D, E, F); 137 | impl_event_list!(A, B, C, D, E, F, G); 138 | impl_event_list!(A, B, C, D, E, F, G, H); 139 | impl_event_list!(A, B, C, D, E, F, G, H, I); 140 | impl_event_list!(A, B, C, D, E, F, G, H, I, J); 141 | impl_event_list!(A, B, C, D, E, F, G, H, I, J, K); 142 | impl_event_list!(A, B, C, D, E, F, G, H, I, J, K, L); 143 | 144 | impl CheckboxValue { 145 | /// Returns a bool indicating if checkbox is checked. 146 | pub fn is_checked(&self) -> bool { 147 | match self { 148 | CheckboxValue::Checked => true, 149 | CheckboxValue::Unchecked => false, 150 | } 151 | } 152 | } 153 | 154 | impl Default for CheckboxValue { 155 | fn default() -> Self { 156 | CheckboxValue::Unchecked 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Submillisecond LiveView provides rich, real-time user experiences with 2 | //! server-rendered HTML. 3 | //! 4 | //! ### Prerequisites 5 | //! 6 | //! [Lunatic runtime] is required, along with the wasm32-wasi target. 7 | //! 8 | //! See [README.md#prerequisites] on how to install Lunatic. 9 | //! 10 | //! [Lunatic runtime]: https://github.com/lunatic-solutions/lunatic-rs#setup 11 | //! [README.md#prerequisites]: https://github.com/lunatic-solutions/submillisecond-live-view#prerequisites 12 | //! 13 | //! ### Quick Start 14 | //! 15 | //! To get started, add `submillisecond`, `submillisecond-live-view`, and 16 | //! `serde` to your Cargo.toml. 17 | //! 18 | //! ```text 19 | //! [dependencies] 20 | //! submillisecond = "*" 21 | //! submillisecond-live-view = "*" 22 | //! serde = { version = "*", features = ["derive"] } 23 | //! ``` 24 | //! 25 | //! We'll also need an index.html file next to our Cargo.toml file to act as a 26 | //! template for our LiveView. The LiveView will be injected in the #app div. 27 | //! 28 | //! ```html 29 | //! 30 | //! 31 | //! My LiveView App 32 | //! 33 | //! 34 | //!
35 | //! 36 | //! 37 | //! ``` 38 | //! 39 | //! Next, implement [`LiveView`] on a new type, and define the 40 | //! [`LiveView::Events`] tuple, [`LiveView::mount`] and [`LiveView::render`] 41 | //! methods. 42 | //! 43 | //! ``` 44 | //! use submillisecond_live_view::prelude::*; 45 | //! use serde::{Deserialize, Serialize}; 46 | //! 47 | //! #[derive(Clone, Serialize, Deserialize)] 48 | //! struct Counter { 49 | //! count: u32, 50 | //! } 51 | //! 52 | //! impl LiveView for Counter { 53 | //! type Events = (Increment, Decrement); 54 | //! 55 | //! fn mount(_uri: Uri, _socket: Option) -> Self { 56 | //! Counter { count: 0 } 57 | //! } 58 | //! 59 | //! fn render(&self) -> Rendered { 60 | //! html! { 61 | //! p { "Count is " (self.count) } 62 | //! button @click=(Increment) { "Increment" } 63 | //! button @click=(Decrement) { "Decrement" } 64 | //! } 65 | //! } 66 | //! } 67 | //! 68 | //! #[derive(Serialize, Deserialize)] 69 | //! struct Increment {} 70 | //! 71 | //! impl LiveViewEvent for Counter { 72 | //! fn handle(state: &mut Self, _event: Increment) { 73 | //! state.count += 1; 74 | //! } 75 | //! } 76 | //! 77 | //! #[derive(Serialize, Deserialize)] 78 | //! struct Decrement {} 79 | //! 80 | //! impl LiveViewEvent for Counter { 81 | //! fn handle(state: &mut Self, _event: Decrement) { 82 | //! state.count -= 1; 83 | //! } 84 | //! } 85 | //! ``` 86 | //! 87 | //! Finally, serve your submillisecond app with the `Counter`. 88 | //! 89 | //! ``` 90 | //! use submillisecond::{router, Application}; 91 | //! 92 | //! fn main() -> std::io::Result<()> { 93 | //! Application::new(router! { 94 | //! GET "/" => Counter::handler("index.html", "#app") 95 | //! }) 96 | //! .serve("127.0.0.1:3000") 97 | //! } 98 | //! ``` 99 | //! 100 | //! ### Html Macro 101 | //! 102 | //! The `html!` macro is an extended version of the [maud] macro, 103 | //! which is available under [`submillisecond_live_view::html!`](html!). 104 | //! 105 | //! Docs for the syntax of the `html!` macro are available on the maud website, 106 | //! but this section documents some syntax features which are specific to 107 | //! Submillisecond LiveView. 108 | //! 109 | //! [maud]: https://maud.lambda.xyz/ 110 | //! 111 | //! #### Events 112 | //! 113 | //! Events can be defined with the `@click=(Increment)` syntax. 114 | //! Where `click` is the event name, and `Increment` is the event to be sent 115 | //! back to the server. 116 | //! 117 | //! This is syntax sugar for `phx-click=(std::any::type_name::())`. 118 | //! 119 | //! **Example** 120 | //! 121 | //! ```rust 122 | //! html! { 123 | //! button @click=(Greet) { "Greet" } 124 | //! } 125 | //! ``` 126 | //! 127 | //! See . 128 | //! 129 | //! #### Values 130 | //! 131 | //! Values can be added to events with the `:name=(value)` syntax. 132 | //! Where `name` is the name of the variable, and `value` is the value. 133 | //! It is typically used along side events to pass data back with the event. 134 | //! 135 | //! This is syntax sugar for `phx-value-name=(value)`. 136 | //! 137 | //! **Example** 138 | //! 139 | //! ```rust 140 | //! html! { 141 | //! button :username=(user.name) @click=(Register) { "Register" } 142 | //! } 143 | //! ``` 144 | //! 145 | //! See . 146 | //! 147 | //! #### Nesting Html 148 | //! 149 | //! Maud supports [partials], but there is a different syntax for nesting 150 | //! renders when using Submillisecond LiveView. 151 | //! 152 | //! Nested renders should use the `@(nested)` syntax. 153 | //! If HTML created with the `html!` macro is nested without the `@` prefix, 154 | //! then it will be rendered as a static string on the page and the content will 155 | //! not be dynamic. 156 | //! 157 | //! **Example** 158 | //! 159 | //! ```rust 160 | //! fn render_header(&self) -> Rendered { 161 | //! html! { 162 | //! h1 { "Header" } 163 | //! } 164 | //! } 165 | //! 166 | //! fn render(&self) -> Rendered { 167 | //! html! { 168 | //! @(self.render_header()) 169 | //! } 170 | //! } 171 | //! ``` 172 | //! 173 | //! [partials]: https://maud.lambda.xyz/partials.html 174 | 175 | #![warn(missing_docs)] 176 | 177 | pub mod handler; 178 | pub mod rendered; 179 | pub mod socket; 180 | 181 | mod csrf; 182 | mod event_handler; 183 | mod live_view; 184 | mod manager; 185 | mod maud; 186 | mod template; 187 | 188 | #[doc(hidden)] 189 | pub use maud_live_view; 190 | pub use maud_live_view::html; 191 | 192 | pub use crate::live_view::*; 193 | 194 | /// Prelude 195 | pub mod prelude { 196 | pub use submillisecond::http::Uri; 197 | 198 | pub use crate::handler::LiveViewRouter; 199 | pub use crate::rendered::Rendered; 200 | pub use crate::socket::Socket; 201 | pub use crate::*; 202 | } 203 | -------------------------------------------------------------------------------- /src/rendered/diff.rs: -------------------------------------------------------------------------------- 1 | use serde_json::{json, Map, Value}; 2 | 3 | use super::strip::Strip; 4 | 5 | /// provides a new `Value` containing the differences between the `old` and 6 | /// `new`. 7 | /// 8 | /// If the `old` Value was `Value::Null` then the `new` Value is returned BUT 9 | /// with the `null` and `empty` values stripped as they have not been changed 10 | /// compared to the old as there was no old and so no difference. 11 | /// ```rust 12 | /// use json_plus::diff; 13 | /// use serde_json::json; 14 | /// 15 | /// fn main() -> Result<(), Box> { 16 | /// let old = json!({"key":"old value", "arr":[]}); 17 | /// let new = json!({"key":"new value", "arr":[]}); 18 | /// 19 | /// let diffed = diff(&old, &new).unwrap(); 20 | /// println!("{}", diffed.to_string()); 21 | /// Ok(()) 22 | /// } 23 | /// ``` 24 | #[inline] 25 | pub fn diff(old: &Value, new: &Value) -> Option { 26 | match old { 27 | Value::Null => super::strip::strip(Strip::Nulls | Strip::Empties, new.clone()), 28 | Value::Array(o) => { 29 | if new.is_array() { 30 | diff_array(o, new.as_array().unwrap()) 31 | } else { 32 | Some(Value::Array(vec![])) 33 | } 34 | } 35 | Value::Object(o) if new.is_object() => diff_map(o, new), 36 | _ => { 37 | if old != new { 38 | if let Value::String(s) = new { 39 | if let Value::Object(o) = old { 40 | if o.contains_key("d") && s.is_empty() { 41 | return Some(json!({ "d": [] })); 42 | } 43 | } 44 | } 45 | Some(new.clone()) 46 | } else { 47 | None 48 | } 49 | } 50 | } 51 | } 52 | 53 | fn diff_array(old: &[Value], new: &[Value]) -> Option { 54 | if old.len() != new.len() { 55 | return Some(Value::Array(new.to_vec())); 56 | } 57 | 58 | for (i, o) in old.iter().enumerate() { 59 | if o != &new[i] { 60 | return Some(Value::Array(new.to_vec())); 61 | } 62 | } 63 | None 64 | } 65 | 66 | fn diff_map(old: &Map, new: &Value) -> Option { 67 | if old.is_empty() { 68 | return Some(new.clone()); 69 | } 70 | 71 | let mut result = Map::new(); 72 | let new_obj = new.as_object().unwrap(); 73 | if new_obj.is_empty() { 74 | for (k, _) in old { 75 | result.insert(k.clone(), Value::Null); 76 | } 77 | return Some(Value::Object(result)); 78 | } 79 | 80 | // need to go over old records first, it's the only way to know new data is no 81 | // longer present. 82 | for (k, v) in old { 83 | match new_obj.get(k) { 84 | Some(n) => match diff(v, n) { 85 | Some(changed) => { 86 | result.insert(k.clone(), changed); 87 | } 88 | None => { 89 | continue; 90 | } 91 | }, 92 | None => { 93 | result.insert(k.clone(), Value::Null); 94 | } 95 | }; 96 | } 97 | 98 | // check for new values that didn't exist in the old 99 | for (k, v) in new_obj { 100 | match old.get(k) { 101 | Some(_) => continue, 102 | None => { 103 | result.insert(k.clone(), v.clone()); 104 | } 105 | } 106 | } 107 | 108 | if result.is_empty() { 109 | return None; 110 | } 111 | Some(Value::Object(result)) 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | 117 | use serde_json::json; 118 | 119 | use super::*; 120 | 121 | #[test] 122 | fn null() { 123 | assert_eq!(diff(&Value::Null, &Value::Null), None); 124 | assert_eq!(diff(&Value::Null, &true.into()), Some(Value::from(true))); 125 | assert_eq!(diff(&true.into(), &Value::Null), Some(Value::Null)); 126 | } 127 | 128 | #[test] 129 | fn bool() { 130 | assert_eq!(diff(&true.into(), &Value::Null), Some(Value::Null)); 131 | assert_eq!(diff(&true.into(), &true.into()), None); 132 | assert_eq!(diff(&Value::Null, &true.into()), Some(Value::from(true))); 133 | assert_eq!(diff(&false.into(), &false.into()), None); 134 | assert_eq!(diff(&false.into(), &true.into()), Some(Value::from(true))); 135 | assert_eq!(diff(&true.into(), &false.into()), Some(Value::from(false))); 136 | } 137 | 138 | #[test] 139 | fn string() { 140 | assert_eq!(diff(&"old".into(), &Value::Null), Some(Value::Null)); 141 | assert_eq!(diff(&Value::Null, &"new".into()), Some(Value::from("new"))); 142 | assert_eq!(diff(&"old".into(), &"old".into()), None); 143 | assert_eq!(diff(&"old".into(), &"new".into()), Some(Value::from("new"))); 144 | } 145 | 146 | #[test] 147 | fn number() { 148 | assert_eq!(diff(&1.into(), &Value::Null), Some(Value::Null)); 149 | assert_eq!(diff(&Value::Null, &1.into()), Some(Value::from(1))); 150 | assert_eq!(diff(&1.into(), &1.into()), None); 151 | assert_eq!(diff(&1.into(), &2.into()), Some(Value::from(2))); 152 | } 153 | 154 | #[test] 155 | fn array() { 156 | // assert_eq!( 157 | // diff(&vec!["val1"].into(), &Value::Null), 158 | // Some(Value::from(Vec::<&str>::new())) 159 | // ); 160 | // assert_eq!( 161 | // diff(&Value::Null, &vec!["val1"].into()), 162 | // Some(Value::from(vec!["val1"])) 163 | // ); 164 | // assert_eq!(diff(&vec!["val1"].into(), &vec!["val1"].into()), None); 165 | // assert_eq!( 166 | // diff(&vec!["val1"].into(), &vec!["val2"].into()), 167 | // Some(Value::from(vec!["val2"])) 168 | // ); 169 | // assert_eq!( 170 | // diff(&vec!["val1", "val2"].into(), &vec!["val1"].into()), 171 | // Some(Value::from(vec!["val1"])) 172 | // ); 173 | // assert_eq!( 174 | // diff(&vec!["val1"].into(), &String::new().into()), 175 | // Some(Value::from(Vec::<&str>::new())) 176 | // ); 177 | // assert_eq!( 178 | // diff(&vec!["val1"].into(), &vec!["val1", "val2"].into()), 179 | // Some(Value::from(vec!["val1", "val2"])) 180 | // ); 181 | 182 | let d = diff( 183 | &json!({ "0": { "d": [ [] ], "s": [ "Hi" ] }, "s": [ "", "" ] }), 184 | &json!({ "0": "", "s": [ "", "" ] }), 185 | ); 186 | assert_eq!(d, Some(json!({ "0": { "d": [] } }))); 187 | } 188 | 189 | #[test] 190 | fn object() { 191 | assert_eq!( 192 | diff(&json!({"key1":1,"key2":"value2"}), &Value::Null), 193 | Some(Value::Null) 194 | ); 195 | assert_eq!( 196 | diff(&Value::Null, &json!({"key1":1,"key2":"value2"})), 197 | Some(json!({"key1":1,"key2":"value2"})) 198 | ); 199 | assert_eq!( 200 | diff( 201 | &json!({"key1":1,"key2":"value2"}), 202 | &json!({"key1":1,"key2":"value2"}) 203 | ), 204 | None 205 | ); 206 | assert_eq!( 207 | diff( 208 | &json!({"key1":1,"key2":"value2","key3":[1,2],"key4":[1,2,3],"key6":true}), 209 | &json!({"key1":1,"key2":"value2","key3":[1,2],"key4":[1,2,3,4],"key5":true}) 210 | ), 211 | Some(json!({"key4":[1,2,3,4],"key5":true,"key6":null})) 212 | ); 213 | assert_eq!( 214 | diff( 215 | &json!({"M":{"a":1,"b":"foo"},"A":["foo"],"B":true}), 216 | &json!({"M":{"a":1,"b":"bar"},"A":["foo","bar"],"B":false}) 217 | ), 218 | Some(json!({"A":["foo","bar"],"B":false,"M":{"b":"bar"}})) 219 | ); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/rendered.rs: -------------------------------------------------------------------------------- 1 | //! Rendered HTML created with the `html!` macro. 2 | 3 | // const DYNAMICS: &str = "d"; 4 | // const STATIC: &str = "s"; 5 | // const COMPONENTS: &str = "c"; 6 | // const EVENTS: &str = "e"; 7 | // const REPLY: &str = "r"; 8 | // const TITLE: &str = "t"; 9 | // const TEMPLATES: &str = "p"; 10 | 11 | mod builder; 12 | mod diff; 13 | mod dynamic; 14 | mod strip; 15 | 16 | use core::fmt; 17 | 18 | use serde::{Deserialize, Serialize}; 19 | use serde_json::{map::Entry, Map, Value}; 20 | 21 | pub use self::builder::*; 22 | use self::{ 23 | dynamic::{Dynamic, DynamicItems, DynamicList, Dynamics}, 24 | strip::Strip, 25 | }; 26 | 27 | /// Rendered HTML containing statics, dynamics and templates. 28 | /// 29 | /// Rendered is typically generated by the `html!` macro. 30 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 31 | pub struct Rendered { 32 | statics: Vec, 33 | dynamics: Dynamics, 34 | templates: Vec>, 35 | } 36 | 37 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 38 | struct RenderedListItem { 39 | statics: usize, 40 | dynamics: Vec>, 41 | } 42 | 43 | /// Converts a type into JSON. 44 | pub trait IntoJson: Sized { 45 | /// Converts value into [`serde_json::Value`]. 46 | fn into_json(self) -> Value { 47 | let mut map = Map::new(); 48 | self.write_json(&mut map); 49 | map.into() 50 | } 51 | 52 | /// Writes properties to an existing map. 53 | fn write_json(self, _map: &mut Map) { 54 | todo!() 55 | } 56 | } 57 | 58 | impl Rendered { 59 | /// Creates a RenderedBuilder. 60 | pub fn builder() -> builder::RenderedBuilder { 61 | builder::RenderedBuilder::new() 62 | } 63 | 64 | /// Diffs self with another [`Rendered`] and returns diff as [`serde_json::Value`]. 65 | pub fn diff(self, other: Rendered) -> Option { 66 | let a = self.into_json(); 67 | let b = other.into_json(); 68 | let diff = diff::diff(&a, &b).unwrap_or_default(); 69 | match diff { 70 | Value::Object(_) => strip::strip(Strip::Nulls.into(), diff), 71 | _ => None, 72 | } 73 | } 74 | } 75 | 76 | impl fmt::Display for Rendered { 77 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 78 | match &self.dynamics { 79 | Dynamics::Items(DynamicItems(items)) => { 80 | for (s, d) in self.statics.iter().zip(items.iter()) { 81 | write!(f, "{s}{d}")?; 82 | } 83 | 84 | if !items.is_empty() { 85 | if let Some(last) = self.statics.last() { 86 | write!(f, "{last}")?; 87 | } 88 | } 89 | } 90 | Dynamics::List(list) => { 91 | for dynamics in &list.0 { 92 | for (s, d) in self.statics.iter().zip(dynamics.iter()) { 93 | write!(f, "{s}")?; 94 | fmt_dynamic_list_item(f, d, &self.templates)?; 95 | } 96 | 97 | if !dynamics.is_empty() { 98 | if let Some(last) = self.statics.last() { 99 | write!(f, "{last}")?; 100 | } 101 | } 102 | } 103 | } 104 | } 105 | 106 | Ok(()) 107 | } 108 | } 109 | 110 | fn fmt_dynamics( 111 | f: &mut fmt::Formatter<'_>, 112 | dynamics: &Dynamics, 113 | statics: &[String], 114 | templates: &[Vec], 115 | ) -> fmt::Result { 116 | match dynamics { 117 | Dynamics::Items(DynamicItems(items)) => { 118 | for (s, d) in statics.iter().zip(items.iter()) { 119 | write!(f, "{s}{d}")?; 120 | } 121 | 122 | if !items.is_empty() { 123 | if let Some(last) = statics.last() { 124 | write!(f, "{last}")?; 125 | } 126 | } 127 | } 128 | Dynamics::List(list) => { 129 | for dynamics in &list.0 { 130 | for (s, d) in statics.iter().zip(dynamics.iter()) { 131 | write!(f, "{s}")?; 132 | fmt_dynamic_list_item(f, d, templates)?; 133 | } 134 | 135 | if !dynamics.is_empty() { 136 | if let Some(last) = statics.last() { 137 | write!(f, "{last}")?; 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | Ok(()) 145 | } 146 | 147 | fn fmt_dynamic_list_item( 148 | f: &mut fmt::Formatter<'_>, 149 | d: &Dynamic, 150 | templates: &[Vec], 151 | ) -> fmt::Result { 152 | match d { 153 | Dynamic::String(s) => { 154 | write!(f, "{s}")?; 155 | } 156 | Dynamic::Nested(n) => { 157 | let statics = templates.get(n.statics).unwrap(); 158 | for (s, d) in statics.iter().zip(n.dynamics.iter()) { 159 | write!(f, "{s}")?; 160 | 161 | fmt_dynamics(f, d, &statics, templates)?; 162 | } 163 | 164 | if !n.dynamics.is_empty() { 165 | if let Some(last) = statics.last() { 166 | write!(f, "{last}")?; 167 | } 168 | } 169 | } 170 | } 171 | 172 | Ok(()) 173 | } 174 | 175 | impl IntoJson for Rendered { 176 | fn write_json(self, map: &mut Map) { 177 | if !self.statics.is_empty() { 178 | map.insert( 179 | "s".to_string(), 180 | Value::Array(self.statics.into_iter().map(|s| s.into()).collect()), 181 | ); 182 | } 183 | 184 | if !self.templates.is_empty() { 185 | let mut templates_map = Map::new(); 186 | for (i, template) in self.templates.into_iter().enumerate() { 187 | templates_map.insert(i.to_string(), template.into()); 188 | } 189 | map.insert("p".to_string(), templates_map.into()); 190 | } 191 | 192 | self.dynamics.write_json(map); 193 | } 194 | } 195 | 196 | impl IntoJson for RenderedListItem { 197 | fn write_json(self, map: &mut Map) { 198 | map.insert("s".to_string(), self.statics.into()); 199 | 200 | let (items, lists): (Vec<_>, Vec<_>) = self 201 | .dynamics 202 | .into_iter() 203 | .map(|d| match d { 204 | Dynamics::Items(items) => (Some(items), None), 205 | Dynamics::List(list) => (None, Some(list)), 206 | }) 207 | .partition(|(a, _)| a.is_some()); 208 | 209 | let items: Vec<_> = items.into_iter().filter_map(|(i, _)| i).collect(); 210 | let lists: Vec<_> = lists.into_iter().filter_map(|(_, l)| l).collect(); 211 | 212 | for (i, dynamic) in items.into_iter().enumerate() { 213 | map.insert(i.to_string(), dynamic.into_json()); 214 | } 215 | 216 | for list in lists.into_iter() { 217 | list.write_json(map); 218 | } 219 | } 220 | } 221 | 222 | impl IntoJson for Dynamics 223 | where 224 | N: IntoJson, 225 | L: IntoJson, 226 | { 227 | fn into_json(self) -> Value { 228 | match self { 229 | Dynamics::Items(items) => items.into_json(), 230 | Dynamics::List(list) => list.into_json(), 231 | } 232 | } 233 | 234 | fn write_json(self, map: &mut Map) { 235 | match self { 236 | Dynamics::Items(items) => items.write_json(map), 237 | Dynamics::List(list) => list.write_json(map), 238 | } 239 | } 240 | } 241 | 242 | impl IntoJson for DynamicItems 243 | where 244 | N: IntoJson, 245 | { 246 | fn write_json(self, map: &mut Map) { 247 | for (i, dynamic) in self.0.into_iter().enumerate() { 248 | map.insert(i.to_string(), dynamic.into_json()); 249 | } 250 | } 251 | } 252 | 253 | impl IntoJson for DynamicList 254 | where 255 | N: IntoJson, 256 | { 257 | fn write_json(self, map: &mut Map) { 258 | if !self.0.iter().any(|list| !list.is_empty()) { 259 | return; 260 | } 261 | 262 | let dynamics = self 263 | .0 264 | .into_iter() 265 | .map(|list| Value::Array(list.into_iter().map(|d| d.into_json()).collect())); 266 | 267 | match map.entry("d".to_string()) { 268 | Entry::Vacant(entry) => { 269 | entry.insert(dynamics.collect::>().into()); 270 | } 271 | Entry::Occupied(mut entry) => match entry.get_mut() { 272 | Value::Array(array) => array.extend(dynamics), 273 | _ => todo!(), 274 | }, 275 | } 276 | } 277 | } 278 | 279 | impl IntoJson for Dynamic 280 | where 281 | N: IntoJson, 282 | { 283 | fn into_json(self) -> Value { 284 | match self { 285 | Dynamic::String(s) => s.into(), 286 | Dynamic::Nested(n) => n.into_json(), 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /examples/todos.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use submillisecond::{router, static_router, Application}; 3 | use submillisecond_live_view::prelude::*; 4 | use uuid::Uuid; 5 | 6 | fn main() -> std::io::Result<()> { 7 | Application::new(router! { 8 | GET "/" => Todos::handler("examples/todos.html", "#app") 9 | "/static" => static_router!("./static") 10 | }) 11 | .serve("127.0.0.1:3000") 12 | } 13 | 14 | #[derive(Clone, Serialize, Deserialize)] 15 | struct Todos { 16 | filter: Filter, 17 | todos: Vec, 18 | } 19 | 20 | impl LiveView for Todos { 21 | type Events = ( 22 | Add, 23 | Remove, 24 | Toggle, 25 | Edit, 26 | ToggleEdit, 27 | ClearCompleted, 28 | SetFilter, 29 | ); 30 | 31 | fn mount(_uri: Uri, _socket: Option) -> Self { 32 | Todos { 33 | filter: Filter::All, 34 | todos: vec![Todo::new("Hello".to_string())], 35 | } 36 | } 37 | 38 | fn render(&self) -> Rendered { 39 | let rendered = html! { 40 | section.todoapp { 41 | @(self.render_header()) 42 | 43 | @if !self.todos.is_empty() { 44 | @(self.render_main()) 45 | @(self.render_footer()) 46 | } 47 | } 48 | }; 49 | 50 | rendered 51 | } 52 | } 53 | 54 | #[derive(Clone, Debug, Serialize, Deserialize)] 55 | struct Todo { 56 | id: Uuid, 57 | title: String, 58 | completed: bool, 59 | editing: bool, 60 | } 61 | 62 | impl Todo { 63 | fn new(title: String) -> Self { 64 | Todo { 65 | id: Uuid::new_v4(), 66 | title, 67 | completed: false, 68 | editing: false, 69 | } 70 | } 71 | } 72 | 73 | #[derive(Deserialize)] 74 | struct Add { 75 | title: String, 76 | } 77 | 78 | impl LiveViewEvent for Todos { 79 | fn handle(state: &mut Self, event: Add) { 80 | state.todos.push(Todo::new(event.title)); 81 | } 82 | } 83 | 84 | #[derive(Deserialize)] 85 | struct Remove { 86 | id: Uuid, 87 | } 88 | 89 | impl LiveViewEvent for Todos { 90 | fn handle(state: &mut Self, event: Remove) { 91 | state.todos.retain(|todo| todo.id != event.id); 92 | } 93 | } 94 | 95 | #[derive(Deserialize)] 96 | struct Toggle { 97 | id: Uuid, 98 | #[serde(default)] 99 | value: CheckboxValue, 100 | } 101 | 102 | impl LiveViewEvent for Todos { 103 | fn handle(state: &mut Self, event: Toggle) { 104 | if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == event.id) { 105 | todo.completed = event.value.is_checked(); 106 | } 107 | } 108 | } 109 | 110 | #[derive(Deserialize)] 111 | struct Edit { 112 | id: Uuid, 113 | title: String, 114 | } 115 | 116 | impl LiveViewEvent for Todos { 117 | fn handle(state: &mut Self, event: Edit) { 118 | if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == event.id) { 119 | todo.title = event.title; 120 | todo.editing = false; 121 | } 122 | } 123 | } 124 | 125 | #[derive(Deserialize)] 126 | struct ToggleEdit { 127 | id: Uuid, 128 | detail: u8, 129 | } 130 | 131 | impl LiveViewEvent for Todos { 132 | fn handle(state: &mut Self, event: ToggleEdit) { 133 | if event.detail == 2 { 134 | if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == event.id) { 135 | todo.editing = true; 136 | } 137 | } 138 | } 139 | } 140 | 141 | #[derive(Deserialize)] 142 | struct ClearCompleted {} 143 | 144 | impl LiveViewEvent for Todos { 145 | fn handle(state: &mut Self, _event: ClearCompleted) { 146 | state.todos.retain(|todo| !todo.completed); 147 | } 148 | } 149 | 150 | #[derive(Deserialize)] 151 | struct SetFilter { 152 | filter: Filter, 153 | } 154 | 155 | impl LiveViewEvent for Todos { 156 | fn handle(state: &mut Self, event: SetFilter) { 157 | state.filter = event.filter; 158 | } 159 | } 160 | 161 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 162 | enum Filter { 163 | All, 164 | Active, 165 | Completed, 166 | } 167 | 168 | impl Todos { 169 | fn render_header(&self) -> Rendered { 170 | html! { 171 | header.header { 172 | h1 { "todos" } 173 | 174 | form #newtodo 175 | method="post" 176 | autocapitalize="off" 177 | autocomplete="off" 178 | autocorrect="off" 179 | spellcheck="false" 180 | url="#" 181 | @submit=(Add) 182 | { 183 | i { 184 | input #newtodo_text .new-todo autofocus name="title" placeholder="What needs to be done?" type="text"; 185 | } 186 | button.hidden type="submit" { "submit" } 187 | } 188 | } 189 | } 190 | } 191 | 192 | fn render_main(&self) -> Rendered { 193 | let visible_todos: Vec<_> = match self.filter { 194 | Filter::All => self.todos.iter().collect(), 195 | Filter::Active => self.todos.iter().filter(|todo| !todo.completed).collect(), 196 | Filter::Completed => self.todos.iter().filter(|todo| todo.completed).collect(), 197 | }; 198 | 199 | html! { 200 | section.main { 201 | input #toggle-all.toggle-all type="checkbox"; 202 | label for="toggle-all" { "Mark all as complete" } 203 | ul.todo-list { 204 | @for todo in visible_todos { 205 | @let classes = match (todo.completed, todo.editing) { 206 | (true, true) => "completed editing", 207 | (true, false) => "completed", 208 | (false, true) => "editing", 209 | (false, false) => "", 210 | }; 211 | li class=(classes) { 212 | @let id = todo.id.to_string(); 213 | form 214 | method="post" 215 | autocapitalize="off" 216 | autocomplete="off" 217 | autocorrect="off" 218 | spellcheck="false" 219 | url="#" 220 | @submit=(Edit) 221 | { 222 | div.view { 223 | input.toggle 224 | type="checkbox" 225 | checked[todo.completed] 226 | :id=(id) 227 | @click=(ToggleEdit); 228 | label :id=(id) @click=(ToggleEdit) { 229 | (todo.title) 230 | } 231 | button.destroy :id=(id) type="button" @click=(Remove) {} 232 | } 233 | input type="hidden" name="id" value=(id); 234 | input.edit name="title" value=(todo.title); 235 | } 236 | } 237 | 238 | } 239 | } 240 | } 241 | } 242 | } 243 | 244 | fn render_footer(&self) -> Rendered { 245 | let remaining_todos = self.todos.iter().filter(|todo| !todo.completed).count(); 246 | let filter_links = [ 247 | ("All", Filter::All), 248 | ("Active", Filter::Active), 249 | ("Completed", Filter::Completed), 250 | ] 251 | .into_iter() 252 | .map(|(label, filter)| (label, filter, filter == self.filter)); 253 | 254 | html! { 255 | section.footer { 256 | span.todo-count { 257 | strong { (remaining_todos) } 258 | " item(s) left" 259 | } 260 | 261 | ul.filters { 262 | @for (label, filter, selected) in filter_links { 263 | li { 264 | @let selected_class = if selected { "selected" } else { "" }; 265 | @let filter_value = serde_json::to_string(&filter).unwrap(); 266 | a 267 | class=(selected_class) 268 | href={"#/" (label)} 269 | :filter=(filter_value.trim_matches('"')) 270 | @click=(SetFilter) 271 | { 272 | (label) 273 | } 274 | } 275 | } 276 | } 277 | 278 | @if remaining_todos > 0 { 279 | button.clear-completed @click=(ClearCompleted) { "Clear completed" } 280 | } 281 | } 282 | 283 | footer.info { 284 | p { "Double-click to edit a todo" } 285 | p { 286 | "Created by " 287 | a href="https://github.com/tqwewe" { "Ari Seyhun" } 288 | } 289 | p { 290 | "Part of " 291 | a href="https://github.com/lunatic-solutions/submillisecond-live-view" { "Submillisecond Live View" } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | //! Handler functionality for handling LiveViews. 2 | 3 | use std::fmt; 4 | use std::marker::PhantomData; 5 | 6 | use lunatic_log::{error, info, trace, warn}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::json; 9 | use submillisecond::extract::FromOwnedRequest; 10 | use submillisecond::http::header; 11 | use submillisecond::response::{IntoResponse, Response}; 12 | use submillisecond::websocket::{WebSocket, WebSocketConnection}; 13 | use submillisecond::{Handler, RequestContext}; 14 | 15 | use crate::event_handler::EventHandler; 16 | use crate::manager::LiveViewManager; 17 | use crate::maud::LiveViewMaud; 18 | use crate::socket::{Message, ProtocolEvent, RawSocket, SocketError, SocketMessage}; 19 | use crate::template::TemplateProcess; 20 | use crate::LiveView; 21 | 22 | type Manager = LiveViewMaud; 23 | 24 | /// A LiveView handler created with `LiveViewRouter::handler`. 25 | pub struct LiveViewHandler<'a, T> { 26 | template: &'a str, 27 | selector: &'a str, 28 | phantom: PhantomData, 29 | } 30 | 31 | /// Trait used to create a handler from a LiveView. 32 | pub trait LiveViewRouter: Sized { 33 | /// Create handler for LiveView with a html template. 34 | /// 35 | /// The LiveView is injected into the selector of the template. 36 | /// 37 | /// # Example 38 | /// 39 | /// ``` 40 | /// router! { 41 | /// GET "/" => MyLiveView::handler("index.html", "#app") 42 | /// } 43 | /// ``` 44 | fn handler<'a>(template: &'a str, selector: &'a str) -> LiveViewHandler<'a, Self>; 45 | } 46 | 47 | trait LogError { 48 | fn log_warn(self); 49 | fn log_error(self); 50 | } 51 | 52 | impl LiveViewRouter for T 53 | where 54 | T: LiveView, 55 | { 56 | fn handler<'a>(template: &'a str, selector: &'a str) -> LiveViewHandler<'a, Self> { 57 | LiveViewHandler::new(template, selector) 58 | } 59 | } 60 | 61 | impl<'a, T> LiveViewHandler<'a, T> { 62 | pub(crate) fn new(template: &'a str, selector: &'a str) -> Self { 63 | LiveViewHandler { 64 | template, 65 | selector, 66 | phantom: PhantomData, 67 | } 68 | } 69 | } 70 | 71 | impl<'a, T> Handler for LiveViewHandler<'a, T> 72 | where 73 | T: LiveView, 74 | { 75 | fn init(&self) { 76 | TemplateProcess::start(self.template, self.selector).expect("failed to load index.html"); 77 | } 78 | 79 | fn handle(&self, req: RequestContext) -> Response { 80 | let process = TemplateProcess::lookup(self.template, self.selector) 81 | .expect("TemplateProcess should be started"); 82 | let live_view: LiveViewMaud = Manager::new(process); 83 | 84 | let is_websocket = req 85 | .headers() 86 | .get(header::UPGRADE) 87 | .and_then(|upgrade| upgrade.to_str().ok()) 88 | .map(|upgrade| upgrade == "websocket") 89 | .unwrap_or(false); 90 | if is_websocket { 91 | let ws = match WebSocket::from_owned_request(req) { 92 | Ok(ws) => ws, 93 | Err(err) => return err.into_response(), 94 | }; 95 | 96 | ws.on_upgrade(live_view, |conn, live_view| { 97 | let (mut socket, mut message) = match wait_for_join(conn) { 98 | Ok((socket, message)) => (socket, message), 99 | Err(err) => { 100 | error!("{err}"); 101 | return; 102 | }, 103 | }; 104 | let mut conn = socket.conn.clone(); 105 | let event_handler = EventHandler::spawn(socket.clone(), live_view); 106 | 107 | match event_handler.handle_join(message.take_join_event().unwrap()) { 108 | Ok(reply) => { 109 | socket.send_reply(message.reply_ok(json!({ "rendered": reply }))).unwrap(); 110 | } 111 | Err(err) => { 112 | error!("{err}"); 113 | return 114 | } 115 | } 116 | 117 | loop { 118 | match RawSocket::receive_from_conn(&mut conn) { 119 | Ok(SocketMessage::Event(message)) => { 120 | if !handle_message::, T>(&mut socket, message, &event_handler) { 121 | break; 122 | } 123 | } 124 | Ok(SocketMessage::Ping(_)) | 125 | Ok(SocketMessage::Pong(_)) => {} 126 | Ok(SocketMessage::Close) => { 127 | info!("Socket connection closed"); 128 | break; 129 | } 130 | Err(SocketError::WebsocketError(tungstenite::Error::AlreadyClosed)) 131 | | Err(SocketError::WebsocketError( 132 | tungstenite::Error::ConnectionClosed, 133 | )) => { 134 | info!("connection closed"); 135 | break; 136 | } 137 | Err(SocketError::WebsocketError(err)) => { 138 | warn!("read message failed: {err}"); 139 | break; 140 | } 141 | Err(SocketError::DeserializeError(err)) => { 142 | warn!("deserialization failed: {err}"); 143 | } 144 | } 145 | } 146 | }) 147 | .into_response() 148 | } else { 149 | live_view.handle_request(req) 150 | } 151 | } 152 | } 153 | 154 | fn wait_for_join(mut conn: WebSocketConnection) -> Result<(RawSocket, Message), SocketError> { 155 | loop { 156 | match RawSocket::receive_from_conn(&mut conn) { 157 | Ok(SocketMessage::Event( 158 | message @ Message { 159 | event: ProtocolEvent::Join, 160 | .. 161 | }, 162 | )) => { 163 | return Ok(( 164 | RawSocket { 165 | conn, 166 | ref1: message.ref1.clone(), 167 | topic: message.topic.clone(), 168 | }, 169 | message, 170 | )); 171 | } 172 | Ok(SocketMessage::Event(Message { 173 | event: ProtocolEvent::Close, 174 | .. 175 | })) 176 | | Ok(SocketMessage::Event(Message { 177 | event: ProtocolEvent::Leave, 178 | .. 179 | })) 180 | | Ok(SocketMessage::Close) => { 181 | return Err(SocketError::WebsocketError( 182 | tungstenite::Error::ConnectionClosed, 183 | )); 184 | } 185 | Ok(SocketMessage::Event(_) | SocketMessage::Ping(_) | SocketMessage::Pong(_)) => {} 186 | Err(SocketError::WebsocketError(err @ tungstenite::Error::AlreadyClosed)) 187 | | Err(SocketError::WebsocketError(err @ tungstenite::Error::ConnectionClosed)) 188 | | Err(SocketError::WebsocketError(err)) => { 189 | return Err(SocketError::WebsocketError(err)); 190 | } 191 | Err(SocketError::DeserializeError(err)) => { 192 | warn!("deserialization failed: {err}"); 193 | } 194 | } 195 | } 196 | } 197 | 198 | fn handle_message( 199 | socket: &mut RawSocket, 200 | mut message: Message, 201 | event_handler: &EventHandler, 202 | ) -> bool 203 | where 204 | L: LiveViewManager + Serialize + for<'de> Deserialize<'de>, 205 | // L::Reply: Serialize + for<'de> Deserialize<'de>, 206 | L::Error: Serialize + for<'de> Deserialize<'de>, 207 | T: LiveView, 208 | { 209 | trace!("Received message: {message:?}"); 210 | match message.event { 211 | ProtocolEvent::Close => { 212 | info!("Client left"); 213 | false 214 | } 215 | ProtocolEvent::Diff => true, 216 | ProtocolEvent::Error => true, 217 | ProtocolEvent::Event => match message.take_event() { 218 | Ok(event) => { 219 | info!("Received event {}", event.name); 220 | match event_handler.handle_event(event) { 221 | Ok(Some(reply)) => { 222 | socket 223 | .send_reply(message.reply_ok(json!({ "diff": reply }))) 224 | .log_warn(); 225 | } 226 | Ok(None) => { 227 | socket.send_reply(message.reply_ok(json!({}))).log_warn(); 228 | } 229 | Err(err) => { 230 | error!("{err}"); 231 | } 232 | } 233 | true 234 | } 235 | Err(err) => { 236 | error!("{err}"); 237 | true 238 | } 239 | }, 240 | ProtocolEvent::Heartbeat => { 241 | socket.send_reply(message.reply_ok(json!({}))).log_error(); 242 | true 243 | } 244 | ProtocolEvent::Join => false, 245 | ProtocolEvent::Leave => { 246 | info!("Client left"); 247 | false 248 | } 249 | ProtocolEvent::Reply => true, 250 | } 251 | } 252 | 253 | impl LogError for Result<(), E> 254 | where 255 | E: fmt::Display, 256 | { 257 | fn log_warn(self) { 258 | if let Err(err) = self { 259 | warn!("{err}"); 260 | } 261 | } 262 | 263 | fn log_error(self) { 264 | if let Err(err) = self { 265 | error!("{err}"); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/socket.rs: -------------------------------------------------------------------------------- 1 | //! WebSocket functionality. 2 | 3 | use std::convert::{TryFrom, TryInto}; 4 | use std::mem; 5 | 6 | use lunatic::{Mailbox, Process}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::{json, Value}; 9 | use submillisecond::websocket::WebSocketConnection; 10 | use thiserror::Error; 11 | 12 | use crate::event_handler::{EventHandler, EventHandlerError}; 13 | 14 | /// Wrapper around a websocket connection to handle phoenix channels. 15 | #[derive(Clone, Debug, Serialize, Deserialize)] 16 | #[serde(bound = "")] 17 | pub struct Socket { 18 | pub(crate) event_handler: EventHandler, 19 | pub(crate) socket: RawSocket, 20 | } 21 | 22 | /// A raw event from the socket. 23 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 24 | pub struct Event { 25 | /// Event name. 26 | #[serde(rename = "event")] 27 | pub name: String, 28 | /// Event type. 29 | #[serde(rename = "type")] 30 | pub ty: String, 31 | /// Event value. 32 | pub value: Value, 33 | } 34 | 35 | /// Wrapper around a websocket connection to handle phoenix channels. 36 | #[derive(Clone, Debug, Serialize, Deserialize)] 37 | pub(crate) struct RawSocket { 38 | pub(crate) conn: WebSocketConnection, 39 | pub(crate) ref1: Option, 40 | pub(crate) topic: String, 41 | } 42 | 43 | /// Protocol-reserved events. 44 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 45 | pub(crate) enum ProtocolEvent { 46 | /// The connection will be closed. 47 | #[serde(rename = "phx_close")] 48 | Close, 49 | /// A tempalte diff. 50 | #[serde(rename = "diff")] 51 | Diff, 52 | /// A channel has errored and needs to be reconnected. 53 | #[serde(rename = "phx_error")] 54 | Error, 55 | /// A live view event. 56 | #[serde(rename = "event")] 57 | Event, 58 | /// Heartbeat. 59 | #[serde(rename = "heartbeat")] 60 | Heartbeat, 61 | /// Joining a channel. (Non-receivable) 62 | #[serde(rename = "phx_join")] 63 | Join, 64 | /// Leaving a channel. (Non-receivable) 65 | #[serde(rename = "phx_leave")] 66 | Leave, 67 | /// Reply to a message sent by the client. 68 | #[serde(rename = "phx_reply")] 69 | Reply, 70 | } 71 | 72 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 73 | pub(crate) struct Message { 74 | pub ref1: Option, 75 | pub ref2: Option, 76 | pub topic: String, 77 | pub event: ProtocolEvent, 78 | pub payload: Value, 79 | } 80 | 81 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 82 | pub(crate) struct JoinEvent { 83 | pub url: Option, 84 | pub redirect: Option, 85 | pub params: JoinEventParams, 86 | pub session: String, 87 | #[serde(rename = "static")] 88 | pub static_token: Option, 89 | } 90 | 91 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 92 | pub(crate) struct JoinEventParams { 93 | #[serde(rename = "_csrf_token")] 94 | pub csrf_token: String, 95 | #[serde(rename = "_mounts")] 96 | pub mounts: u32, 97 | #[serde(rename = "_track_static", default)] 98 | pub track_static: Vec, 99 | } 100 | 101 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 102 | #[serde(rename_all = "snake_case")] 103 | pub(crate) enum Status { 104 | Ok, 105 | Error, 106 | } 107 | 108 | pub(crate) enum SocketMessage { 109 | Event(Message), 110 | Close, 111 | Ping(Vec), 112 | Pong(Vec), 113 | } 114 | 115 | #[derive(Debug, Error)] 116 | pub(crate) enum SocketError { 117 | #[error(transparent)] 118 | WebsocketError(#[from] tungstenite::Error), 119 | #[error(transparent)] 120 | DeserializeError(#[from] serde_json::Error), 121 | } 122 | 123 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 124 | struct Response { 125 | status: Status, 126 | response: T, 127 | } 128 | 129 | impl Socket { 130 | /// Sends an event and wait for it to be sent to the socket. 131 | /// 132 | /// If you intend on sending an event from an event handler, use 133 | /// [`Socket::spawn_send_event`]. 134 | pub fn send_event(&mut self, event: E) -> Result<(), EventHandlerError> 135 | where 136 | E: Serialize, 137 | { 138 | Self::_send_event(event, &self.event_handler, &mut self.socket) 139 | } 140 | 141 | /// Sends an event in a spawned process. 142 | /// 143 | /// Use this if you intend to send an event from within an event handler. 144 | pub fn spawn_send_event(&mut self, event: E) 145 | where 146 | E: Serialize + for<'de> Deserialize<'de>, 147 | { 148 | Process::spawn( 149 | (event, self.event_handler.clone(), self.socket.clone()), 150 | |(event, event_handler, mut socket), _: Mailbox<()>| { 151 | Self::_send_event(event, &event_handler, &mut socket).unwrap(); 152 | }, 153 | ); 154 | // TODO: Use this code when is merged and published. 155 | // spawn!(|event, event_handler = { self.event_handler.clone() }, socket 156 | // = { self.socket.clone() }| { 157 | // Self::_send_event(event, &event_handler, &mut socket).unwrap(); 158 | // }); 159 | } 160 | 161 | fn _send_event( 162 | event: E, 163 | event_handler: &EventHandler, 164 | socket: &mut RawSocket, 165 | ) -> Result<(), EventHandlerError> 166 | where 167 | E: Serialize, 168 | { 169 | let value = serde_json::to_value(event).map_err(|_| EventHandlerError::SerializeEvent)?; 170 | let reply = event_handler.handle_event(Event { 171 | name: std::any::type_name::().to_string(), 172 | ty: "internal".to_string(), 173 | value, 174 | })?; 175 | let msg = match reply { 176 | Some(reply) => reply, 177 | None => json!({}), 178 | }; 179 | socket 180 | .send(ProtocolEvent::Diff, &msg) 181 | .map_err(|err| EventHandlerError::SocketError(err.to_string())) 182 | } 183 | } 184 | 185 | impl RawSocket { 186 | // pub fn receive(&mut self) -> Result { 187 | // Self::receive_from_conn(&mut self.conn) 188 | // } 189 | 190 | pub fn receive_from_conn(conn: &mut WebSocketConnection) -> Result { 191 | let message = conn.read_message()?; 192 | message.try_into() 193 | } 194 | 195 | pub fn send(&mut self, event: ProtocolEvent, value: &T) -> Result<(), SocketError> 196 | where 197 | T: Serialize, 198 | { 199 | let protocol_event = serde_json::to_value(event)?; 200 | let text = serde_json::to_string(&json!([ 201 | &self.ref1, 202 | &None::<()>, 203 | &self.topic, 204 | &protocol_event, 205 | value, 206 | ]))?; 207 | 208 | Ok(self.conn.write_message(tungstenite::Message::Text(text))?) 209 | } 210 | 211 | pub fn send_reply(&mut self, message: &Message) -> Result<(), SocketError> { 212 | let text = serde_json::to_string(&message.to_tuple())?; 213 | Ok(self.conn.write_message(tungstenite::Message::Text(text))?) 214 | } 215 | } 216 | 217 | impl Message { 218 | pub fn reply_ok(&mut self, response: T) -> &mut Self 219 | where 220 | T: Serialize, 221 | { 222 | self.event = ProtocolEvent::Reply; 223 | self.payload = serde_json::to_value(Response { 224 | status: Status::Ok, 225 | response, 226 | }) 227 | .unwrap(); 228 | self 229 | } 230 | 231 | // pub fn reply_err(&mut self, response: T) -> &mut Self 232 | // where 233 | // T: Serialize, 234 | // { 235 | // self.event = ProtocolEvent::Reply; 236 | // self.payload = serde_json::to_value(Response { 237 | // status: Status::Error, 238 | // response, 239 | // }) 240 | // .unwrap(); 241 | // self 242 | // } 243 | 244 | pub fn take_event(&mut self) -> Result { 245 | serde_json::from_value(mem::take(&mut self.payload)) 246 | } 247 | 248 | pub fn take_join_event(&mut self) -> Result { 249 | serde_json::from_value(mem::take(&mut self.payload)) 250 | } 251 | 252 | fn to_tuple( 253 | &self, 254 | ) -> ( 255 | &Option, 256 | &Option, 257 | &String, 258 | &ProtocolEvent, 259 | &Value, 260 | ) { 261 | ( 262 | &self.ref1, 263 | &self.ref2, 264 | &self.topic, 265 | &self.event, 266 | &self.payload, 267 | ) 268 | } 269 | 270 | fn from_tuple( 271 | (ref1, ref2, topic, event, payload): ( 272 | Option, 273 | Option, 274 | String, 275 | ProtocolEvent, 276 | Value, 277 | ), 278 | ) -> Self { 279 | Message { 280 | ref1, 281 | ref2, 282 | topic, 283 | event, 284 | payload, 285 | } 286 | } 287 | } 288 | 289 | impl JoinEvent { 290 | pub fn url(&self) -> Option<&String> { 291 | self.url.as_ref().or(self.redirect.as_ref()) 292 | } 293 | } 294 | 295 | impl TryFrom for SocketMessage { 296 | type Error = SocketError; 297 | 298 | fn try_from(message: tungstenite::Message) -> Result { 299 | match message { 300 | tungstenite::Message::Text(text) => { 301 | let items = serde_json::from_str(&text)?; 302 | Ok(SocketMessage::Event(Message::from_tuple(items))) 303 | } 304 | tungstenite::Message::Binary(bytes) => { 305 | let items = serde_json::from_slice(&bytes)?; 306 | Ok(SocketMessage::Event(Message::from_tuple(items))) 307 | } 308 | tungstenite::Message::Ping(data) => Ok(SocketMessage::Ping(data)), 309 | tungstenite::Message::Pong(data) => Ok(SocketMessage::Pong(data)), 310 | tungstenite::Message::Close(_) => Ok(SocketMessage::Close), 311 | tungstenite::Message::Frame(_) => { 312 | unreachable!("frame should not be received with read_message"); 313 | } 314 | } 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /tests/diff.rs: -------------------------------------------------------------------------------- 1 | use pretty_assertions::assert_eq; 2 | use serde_json::json; 3 | use submillisecond_live_view::html; 4 | 5 | #[lunatic::test] 6 | fn dynamic_diff() { 7 | let render = |s: &str| { 8 | html! { 9 | a href={ (s) "/lambda-fairy/maud" } { 10 | "Hello, world!" 11 | } 12 | } 13 | }; 14 | 15 | let diff = render("hey").diff(render("there")); 16 | assert_eq!( 17 | diff, 18 | Some(json!({ 19 | "0": "there" 20 | })) 21 | ); 22 | } 23 | 24 | #[lunatic::test] 25 | fn if_statement_false_to_true_diff() { 26 | let render = |logged_in: bool| { 27 | html! { 28 | "Welcome " 29 | @if logged_in { 30 | "person" 31 | } 32 | "." 33 | } 34 | }; 35 | 36 | let diff = render(false).diff(render(true)); 37 | assert_eq!( 38 | diff, 39 | Some(json!({ 40 | "0": { 41 | "s": [ 42 | "person" 43 | ] 44 | } 45 | })) 46 | ); 47 | 48 | let render = |logged_in: bool| { 49 | html! { 50 | "Welcome " 51 | @if logged_in { 52 | (logged_in.to_string()) 53 | } 54 | "." 55 | } 56 | }; 57 | 58 | let diff = render(false).diff(render(true)); 59 | assert_eq!( 60 | diff, 61 | Some(json!({ 62 | "0": { 63 | "0": "true", 64 | "s": [ 65 | "", 66 | "" 67 | ] 68 | } 69 | })) 70 | ); 71 | } 72 | 73 | #[lunatic::test] 74 | fn if_statement_true_to_false_diff() { 75 | let render = |logged_in: bool| { 76 | html! { 77 | "Welcome " 78 | @if logged_in { 79 | "person" 80 | } 81 | "." 82 | } 83 | }; 84 | 85 | let diff = render(true).diff(render(false)); 86 | assert_eq!( 87 | diff, 88 | Some(json!({ 89 | "0": "" 90 | })) 91 | ); 92 | 93 | let render = |logged_in: bool| { 94 | html! { 95 | "Welcome " 96 | @if logged_in { 97 | (logged_in.to_string()) 98 | } 99 | "." 100 | } 101 | }; 102 | 103 | let diff = render(true).diff(render(false)); 104 | assert_eq!( 105 | diff, 106 | Some(json!({ 107 | "0": "" 108 | })) 109 | ); 110 | } 111 | 112 | #[lunatic::test] 113 | fn if_statement_let_none_to_some_diff() { 114 | let render = |user: Option<&str>| { 115 | html! { 116 | "Welcome " 117 | @if let Some(user) = user { 118 | (user) 119 | } @else { 120 | "stranger" 121 | } 122 | } 123 | }; 124 | 125 | let diff = render(None).diff(render(Some("Bob"))); 126 | assert_eq!( 127 | diff, 128 | Some(json!({ 129 | "0": { 130 | "0": "Bob", 131 | "s": [ 132 | "", 133 | "" 134 | ] 135 | } 136 | })) 137 | ); 138 | } 139 | 140 | #[lunatic::test] 141 | fn if_statement_let_some_to_none_diff() { 142 | let render = |user: Option<&str>| { 143 | html! { 144 | "Welcome " 145 | @if let Some(user) = user { 146 | (user) 147 | } @else { 148 | "stranger" 149 | } 150 | } 151 | }; 152 | 153 | let diff = render(Some("Bob")).diff(render(None)); 154 | assert_eq!( 155 | diff, 156 | Some(json!({ 157 | "0": { 158 | "s": [ 159 | "stranger" 160 | ] 161 | } 162 | })) 163 | ); 164 | } 165 | 166 | #[lunatic::test] 167 | fn if_statement_nested_diff() { 168 | let render = |count: i32| { 169 | html! { 170 | @if count >= 1 { 171 | p { "Count is high" } 172 | @if count >= 2 { 173 | p { "Count is very high!" } 174 | } 175 | } 176 | } 177 | }; 178 | 179 | let diff = render(0).diff(render(1)); 180 | assert_eq!( 181 | diff, 182 | Some(json!({ 183 | "0": { 184 | "0": "", 185 | "s": [ 186 | "

Count is high

", 187 | "" 188 | ] 189 | } 190 | })) 191 | ); 192 | 193 | let diff = render(1).diff(render(2)); 194 | assert_eq!( 195 | diff, 196 | Some(json!({ 197 | "0": { 198 | "0": { 199 | "s": [ 200 | "

Count is very high!

" 201 | ] 202 | } 203 | } 204 | })) 205 | ); 206 | 207 | let diff = render(2).diff(render(3)); 208 | assert_eq!(diff, None); 209 | } 210 | 211 | #[lunatic::test] 212 | fn for_loop_statics_diff() { 213 | let render = |names: &[&str]| { 214 | html! { 215 | @for _ in names { 216 | span { "Hi" } 217 | } 218 | } 219 | }; 220 | 221 | let diff = render(&[]).diff(render(&["John"])); 222 | assert_eq!( 223 | diff, 224 | Some(json!({ 225 | "0": { 226 | "d": [ 227 | [] 228 | ], 229 | "s": [ 230 | "Hi" 231 | ] 232 | } 233 | })) 234 | ); 235 | 236 | let diff = render(&["John"]).diff(render(&["John", "Jim"])); 237 | assert_eq!( 238 | diff, 239 | Some(json!({ 240 | "0": { 241 | "d": [ 242 | [], 243 | [] 244 | ] 245 | } 246 | })) 247 | ); 248 | 249 | let diff = render(&["John", "Jim"]).diff(render(&["John"])); 250 | assert_eq!( 251 | diff, 252 | Some(json!({ 253 | "0": { 254 | "d": [ 255 | [] 256 | ] 257 | } 258 | })) 259 | ); 260 | 261 | let diff = render(&["John"]).diff(render(&[])); 262 | assert_eq!( 263 | diff, 264 | Some(json!({ 265 | "0": { 266 | "d": [] 267 | } 268 | })) 269 | ); 270 | } 271 | 272 | #[lunatic::test] 273 | fn for_loop_dynamics_diff() { 274 | let render = |names: &[&str]| { 275 | html! { 276 | @for name in names { 277 | span { (name) } 278 | } 279 | } 280 | }; 281 | 282 | let diff = render(&[]).diff(render(&["John"])); 283 | assert_eq!( 284 | diff, 285 | Some(json!({ 286 | "0": { 287 | "d": [ 288 | [ 289 | "John" 290 | ] 291 | ], 292 | "s": [ 293 | "", 294 | "" 295 | ] 296 | } 297 | })) 298 | ); 299 | 300 | let diff = render(&["John"]).diff(render(&["John", "Joe"])); 301 | assert_eq!( 302 | diff, 303 | Some(json!({ 304 | "0": { 305 | "d": [ 306 | [ 307 | "John" 308 | ], 309 | [ 310 | "Joe" 311 | ] 312 | ] 313 | } 314 | })) 315 | ); 316 | 317 | let diff = render(&["John", "Joe"]).diff(render(&["John", "Joe", "Jim"])); 318 | assert_eq!( 319 | diff, 320 | Some(json!({ 321 | "0": { 322 | "d": [ 323 | [ 324 | "John" 325 | ], 326 | [ 327 | "Joe" 328 | ], 329 | [ 330 | "Jim" 331 | ] 332 | ] 333 | } 334 | })) 335 | ); 336 | 337 | let diff = render(&["John", "Joe"]).diff(render(&["John"])); 338 | assert_eq!( 339 | diff, 340 | Some(json!({ 341 | "0": { 342 | "d": [ 343 | [ 344 | "John" 345 | ] 346 | ] 347 | } 348 | })) 349 | ); 350 | 351 | let diff = render(&["John"]).diff(render(&[])); 352 | assert_eq!( 353 | diff, 354 | Some(json!({ 355 | "0": { 356 | "d": [] 357 | } 358 | })) 359 | ); 360 | } 361 | 362 | #[lunatic::test] 363 | fn for_loop_nested_diff() { 364 | let render = |names: &[&[&str]]| { 365 | html! { 366 | @for names in names { 367 | @for name in *names { 368 | span { (name) } 369 | } 370 | } 371 | } 372 | }; 373 | 374 | let diff = render(&[]).diff(render(&[&["Hello"]])); 375 | assert_eq!( 376 | diff, 377 | Some(json!({ 378 | "0": { 379 | "d": [ 380 | [ 381 | { 382 | "d": [ 383 | [ 384 | "Hello" 385 | ] 386 | ], 387 | "s": 0 388 | } 389 | ] 390 | ], 391 | "p": { 392 | "0": [ 393 | "", 394 | "" 395 | ] 396 | }, 397 | "s": [ 398 | "", 399 | "" 400 | ] 401 | } 402 | })) 403 | ); 404 | 405 | let render = |names: &[&[&str]]| { 406 | html! { 407 | @for names in names { 408 | @for name in *names { 409 | span { (name) } 410 | @if name == &"World" { 411 | div { "!!!" } 412 | } 413 | } 414 | } 415 | } 416 | }; 417 | 418 | let diff = render(&[]).diff(render(&[&["Hello", "World"]])); 419 | assert_eq!( 420 | diff, 421 | Some(json!({ 422 | "0": { 423 | "d": [ 424 | [ 425 | { 426 | "d": [ 427 | [ 428 | "Hello", 429 | "" 430 | ], 431 | [ 432 | "World", 433 | { 434 | "s": 0 435 | } 436 | ] 437 | ], 438 | "s": 1 439 | } 440 | ] 441 | ], 442 | "p": { 443 | "0": [ 444 | "
!!!
" 445 | ], 446 | "1": [ 447 | "", 448 | "", 449 | "" 450 | ] 451 | }, 452 | "s": [ 453 | "", 454 | "" 455 | ] 456 | } 457 | })) 458 | ); 459 | } 460 | -------------------------------------------------------------------------------- /static/todos.css: -------------------------------------------------------------------------------- 1 | /* https://unpkg.com/todomvc-app-css@2.4.2/index.css */ 2 | 3 | @charset "utf-8"; 4 | 5 | html, 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | button { 12 | margin: 0; 13 | padding: 0; 14 | border: 0; 15 | background: none; 16 | font-size: 100%; 17 | vertical-align: baseline; 18 | font-family: inherit; 19 | font-weight: inherit; 20 | color: inherit; 21 | -webkit-appearance: none; 22 | appearance: none; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | body { 28 | font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif; 29 | line-height: 1.4em; 30 | background: #f5f5f5; 31 | color: #111111; 32 | min-width: 230px; 33 | max-width: 550px; 34 | margin: 0 auto; 35 | -webkit-font-smoothing: antialiased; 36 | -moz-osx-font-smoothing: grayscale; 37 | font-weight: 300; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); 49 | } 50 | 51 | .todoapp input::-webkit-input-placeholder { 52 | font-style: italic; 53 | font-weight: 400; 54 | color: rgba(0, 0, 0, 0.4); 55 | } 56 | 57 | .todoapp input::-moz-placeholder { 58 | font-style: italic; 59 | font-weight: 400; 60 | color: rgba(0, 0, 0, 0.4); 61 | } 62 | 63 | .todoapp input::input-placeholder { 64 | font-style: italic; 65 | font-weight: 400; 66 | color: rgba(0, 0, 0, 0.4); 67 | } 68 | 69 | .todoapp h1 { 70 | position: absolute; 71 | top: -140px; 72 | width: 100%; 73 | font-size: 80px; 74 | font-weight: 200; 75 | text-align: center; 76 | color: #b83f45; 77 | -webkit-text-rendering: optimizeLegibility; 78 | -moz-text-rendering: optimizeLegibility; 79 | text-rendering: optimizeLegibility; 80 | } 81 | 82 | .new-todo, 83 | .edit { 84 | position: relative; 85 | margin: 0; 86 | width: 100%; 87 | font-size: 24px; 88 | font-family: inherit; 89 | font-weight: inherit; 90 | line-height: 1.4em; 91 | color: inherit; 92 | padding: 6px; 93 | border: 1px solid #999; 94 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 95 | box-sizing: border-box; 96 | -webkit-font-smoothing: antialiased; 97 | -moz-osx-font-smoothing: grayscale; 98 | } 99 | 100 | .new-todo { 101 | padding: 16px 16px 16px 60px; 102 | height: 65px; 103 | border: none; 104 | background: rgba(0, 0, 0, 0.003); 105 | box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); 106 | } 107 | 108 | .main { 109 | position: relative; 110 | z-index: 2; 111 | border-top: 1px solid #e6e6e6; 112 | } 113 | 114 | .toggle-all { 115 | width: 1px; 116 | height: 1px; 117 | border: none; /* Mobile Safari */ 118 | opacity: 0; 119 | position: absolute; 120 | right: 100%; 121 | bottom: 100%; 122 | } 123 | 124 | .toggle-all + label { 125 | display: flex; 126 | align-items: center; 127 | justify-content: center; 128 | width: 45px; 129 | height: 65px; 130 | font-size: 0; 131 | position: absolute; 132 | top: -65px; 133 | left: -0; 134 | } 135 | 136 | .toggle-all + label:before { 137 | content: "❯"; 138 | display: inline-block; 139 | font-size: 22px; 140 | color: #949494; 141 | padding: 10px 27px 10px 27px; 142 | -webkit-transform: rotate(90deg); 143 | transform: rotate(90deg); 144 | } 145 | 146 | .toggle-all:checked + label:before { 147 | color: #484848; 148 | } 149 | 150 | .todo-list { 151 | margin: 0; 152 | padding: 0; 153 | list-style: none; 154 | } 155 | 156 | .todo-list li { 157 | position: relative; 158 | font-size: 24px; 159 | border-bottom: 1px solid #ededed; 160 | } 161 | 162 | .todo-list li:last-child { 163 | border-bottom: none; 164 | } 165 | 166 | .todo-list li.editing { 167 | border-bottom: none; 168 | padding: 0; 169 | } 170 | 171 | .todo-list li.editing .edit { 172 | display: block; 173 | width: calc(100% - 43px); 174 | padding: 12px 16px; 175 | margin: 0 0 0 43px; 176 | } 177 | 178 | .todo-list li.editing .view { 179 | display: none; 180 | } 181 | 182 | .todo-list li .toggle { 183 | text-align: center; 184 | width: 40px; 185 | /* auto, since non-WebKit browsers doesn't support input styling */ 186 | height: auto; 187 | position: absolute; 188 | top: 0; 189 | bottom: 0; 190 | margin: auto 0; 191 | border: none; /* Mobile Safari */ 192 | -webkit-appearance: none; 193 | appearance: none; 194 | } 195 | 196 | .todo-list li .toggle { 197 | opacity: 0; 198 | } 199 | 200 | .todo-list li .toggle + label { 201 | /* 202 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 203 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 204 | */ 205 | background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); 206 | background-repeat: no-repeat; 207 | background-position: center left; 208 | } 209 | 210 | .todo-list li .toggle:checked + label { 211 | background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E"); 212 | } 213 | 214 | .todo-list li label { 215 | word-break: break-all; 216 | padding: 15px 15px 15px 60px; 217 | display: block; 218 | line-height: 1.2; 219 | transition: color 0.4s; 220 | font-weight: 400; 221 | color: #484848; 222 | } 223 | 224 | .todo-list li.completed label { 225 | color: #949494; 226 | text-decoration: line-through; 227 | } 228 | 229 | .todo-list li .destroy { 230 | display: none; 231 | position: absolute; 232 | top: 0; 233 | right: 10px; 234 | bottom: 0; 235 | width: 40px; 236 | height: 40px; 237 | margin: auto 0; 238 | font-size: 30px; 239 | color: #949494; 240 | transition: color 0.2s ease-out; 241 | } 242 | 243 | .todo-list li .destroy:hover, 244 | .todo-list li .destroy:focus { 245 | color: #c18585; 246 | } 247 | 248 | .todo-list li .destroy:after { 249 | content: "×"; 250 | display: block; 251 | height: 100%; 252 | line-height: 1.1; 253 | } 254 | 255 | .todo-list li:hover .destroy { 256 | display: block; 257 | } 258 | 259 | .todo-list li .edit { 260 | display: none; 261 | } 262 | 263 | .todo-list li.editing:last-child { 264 | margin-bottom: -1px; 265 | } 266 | 267 | .footer { 268 | padding: 10px 15px; 269 | height: 20px; 270 | text-align: center; 271 | font-size: 15px; 272 | border-top: 1px solid #e6e6e6; 273 | } 274 | 275 | .footer:before { 276 | content: ""; 277 | position: absolute; 278 | right: 0; 279 | bottom: 0; 280 | left: 0; 281 | height: 50px; 282 | overflow: hidden; 283 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 284 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 285 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 286 | } 287 | 288 | .todo-count { 289 | float: left; 290 | text-align: left; 291 | } 292 | 293 | .todo-count strong { 294 | font-weight: 300; 295 | } 296 | 297 | .filters { 298 | margin: 0; 299 | padding: 0; 300 | list-style: none; 301 | position: absolute; 302 | right: 0; 303 | left: 0; 304 | } 305 | 306 | .filters li { 307 | display: inline; 308 | } 309 | 310 | .filters li a { 311 | color: inherit; 312 | margin: 3px; 313 | padding: 3px 7px; 314 | text-decoration: none; 315 | border: 1px solid transparent; 316 | border-radius: 3px; 317 | } 318 | 319 | .filters li a:hover { 320 | border-color: #db7676; 321 | } 322 | 323 | .filters li a.selected { 324 | border-color: #ce4646; 325 | } 326 | 327 | .clear-completed, 328 | html .clear-completed:active { 329 | float: right; 330 | position: relative; 331 | line-height: 19px; 332 | text-decoration: none; 333 | cursor: pointer; 334 | } 335 | 336 | .clear-completed:hover { 337 | text-decoration: underline; 338 | } 339 | 340 | .info { 341 | margin: 65px auto 0; 342 | color: #4d4d4d; 343 | font-size: 11px; 344 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 345 | text-align: center; 346 | } 347 | 348 | .info p { 349 | line-height: 1; 350 | } 351 | 352 | .info a { 353 | color: inherit; 354 | text-decoration: none; 355 | font-weight: 400; 356 | } 357 | 358 | .info a:hover { 359 | text-decoration: underline; 360 | } 361 | 362 | /* 363 | Hack to remove background from Mobile Safari. 364 | Can't use it globally since it destroys checkboxes in Firefox 365 | */ 366 | @media screen and (-webkit-min-device-pixel-ratio: 0) { 367 | .toggle-all, 368 | .todo-list li .toggle { 369 | background: none; 370 | } 371 | 372 | .todo-list li .toggle { 373 | height: 40px; 374 | } 375 | } 376 | 377 | @media (max-width: 430px) { 378 | .footer { 379 | height: 50px; 380 | } 381 | 382 | .filters { 383 | bottom: 10px; 384 | } 385 | } 386 | 387 | :focus, 388 | .toggle:focus + label, 389 | .toggle-all:focus + label { 390 | box-shadow: 0 0 2px 2px #cf7d7d; 391 | outline: 0; 392 | } 393 | 394 | /* https://unpkg.com/todomvc-common@1.0.5/base.css */ 395 | 396 | hr { 397 | margin: 20px 0; 398 | border: 0; 399 | border-top: 1px dashed #c5c5c5; 400 | border-bottom: 1px dashed #f7f7f7; 401 | } 402 | 403 | .learn a { 404 | font-weight: normal; 405 | text-decoration: none; 406 | color: #b83f45; 407 | } 408 | 409 | .learn a:hover { 410 | text-decoration: underline; 411 | color: #787e7e; 412 | } 413 | 414 | .learn h3, 415 | .learn h4, 416 | .learn h5 { 417 | margin: 10px 0; 418 | font-weight: 500; 419 | line-height: 1.2; 420 | color: #000; 421 | } 422 | 423 | .learn h3 { 424 | font-size: 24px; 425 | } 426 | 427 | .learn h4 { 428 | font-size: 18px; 429 | } 430 | 431 | .learn h5 { 432 | margin-bottom: 0; 433 | font-size: 14px; 434 | } 435 | 436 | .learn ul { 437 | padding: 0; 438 | margin: 0 0 30px 25px; 439 | } 440 | 441 | .learn li { 442 | line-height: 20px; 443 | } 444 | 445 | .learn p { 446 | font-size: 15px; 447 | font-weight: 300; 448 | line-height: 1.3; 449 | margin-top: 0; 450 | margin-bottom: 0; 451 | } 452 | 453 | #issue-count { 454 | display: none; 455 | } 456 | 457 | .quote { 458 | border: none; 459 | margin: 20px 0 60px 0; 460 | } 461 | 462 | .quote p { 463 | font-style: italic; 464 | } 465 | 466 | .quote p:before { 467 | content: "“"; 468 | font-size: 50px; 469 | opacity: 0.15; 470 | position: absolute; 471 | top: -20px; 472 | left: 3px; 473 | } 474 | 475 | .quote p:after { 476 | content: "”"; 477 | font-size: 50px; 478 | opacity: 0.15; 479 | position: absolute; 480 | bottom: -42px; 481 | right: 3px; 482 | } 483 | 484 | .quote footer { 485 | position: absolute; 486 | bottom: -40px; 487 | right: 0; 488 | } 489 | 490 | .quote footer img { 491 | border-radius: 3px; 492 | } 493 | 494 | .quote footer a { 495 | margin-left: 5px; 496 | vertical-align: middle; 497 | } 498 | 499 | .speech-bubble { 500 | position: relative; 501 | padding: 10px; 502 | background: rgba(0, 0, 0, 0.04); 503 | border-radius: 5px; 504 | } 505 | 506 | .speech-bubble:after { 507 | content: ""; 508 | position: absolute; 509 | top: 100%; 510 | right: 30px; 511 | border: 13px solid transparent; 512 | border-top-color: rgba(0, 0, 0, 0.04); 513 | } 514 | 515 | .learn-bar > .learn { 516 | position: absolute; 517 | width: 272px; 518 | top: 8px; 519 | left: -300px; 520 | padding: 10px; 521 | border-radius: 5px; 522 | background-color: rgba(255, 255, 255, 0.6); 523 | transition-property: left; 524 | transition-duration: 500ms; 525 | } 526 | 527 | @media (min-width: 899px) { 528 | .learn-bar { 529 | width: auto; 530 | padding-left: 300px; 531 | } 532 | 533 | .learn-bar > .learn { 534 | left: 8px; 535 | } 536 | } 537 | -------------------------------------------------------------------------------- /web/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "web", 9 | "version": "0.0.0", 10 | "dependencies": { 11 | "phoenix": "^1.6.11", 12 | "phoenix_live_view": "^0.17.11", 13 | "topbar": "^1.0.1" 14 | }, 15 | "devDependencies": { 16 | "@rollup/plugin-commonjs": "^22.0.2", 17 | "@rollup/plugin-node-resolve": "^14.0.1", 18 | "@rollup/plugin-terser": "^0.1.0", 19 | "rollup": "^2.79.0", 20 | "rollup-plugin-inject-process-env": "^1.3.1" 21 | } 22 | }, 23 | "node_modules/@jridgewell/gen-mapping": { 24 | "version": "0.3.2", 25 | "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", 26 | "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", 27 | "dev": true, 28 | "dependencies": { 29 | "@jridgewell/set-array": "^1.0.1", 30 | "@jridgewell/sourcemap-codec": "^1.4.10", 31 | "@jridgewell/trace-mapping": "^0.3.9" 32 | }, 33 | "engines": { 34 | "node": ">=6.0.0" 35 | } 36 | }, 37 | "node_modules/@jridgewell/resolve-uri": { 38 | "version": "3.1.0", 39 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", 40 | "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", 41 | "dev": true, 42 | "engines": { 43 | "node": ">=6.0.0" 44 | } 45 | }, 46 | "node_modules/@jridgewell/set-array": { 47 | "version": "1.1.2", 48 | "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", 49 | "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", 50 | "dev": true, 51 | "engines": { 52 | "node": ">=6.0.0" 53 | } 54 | }, 55 | "node_modules/@jridgewell/source-map": { 56 | "version": "0.3.2", 57 | "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", 58 | "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", 59 | "dev": true, 60 | "dependencies": { 61 | "@jridgewell/gen-mapping": "^0.3.0", 62 | "@jridgewell/trace-mapping": "^0.3.9" 63 | } 64 | }, 65 | "node_modules/@jridgewell/sourcemap-codec": { 66 | "version": "1.4.14", 67 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", 68 | "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", 69 | "dev": true 70 | }, 71 | "node_modules/@jridgewell/trace-mapping": { 72 | "version": "0.3.17", 73 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", 74 | "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", 75 | "dev": true, 76 | "dependencies": { 77 | "@jridgewell/resolve-uri": "3.1.0", 78 | "@jridgewell/sourcemap-codec": "1.4.14" 79 | } 80 | }, 81 | "node_modules/@rollup/plugin-commonjs": { 82 | "version": "22.0.2", 83 | "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", 84 | "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", 85 | "dev": true, 86 | "dependencies": { 87 | "@rollup/pluginutils": "^3.1.0", 88 | "commondir": "^1.0.1", 89 | "estree-walker": "^2.0.1", 90 | "glob": "^7.1.6", 91 | "is-reference": "^1.2.1", 92 | "magic-string": "^0.25.7", 93 | "resolve": "^1.17.0" 94 | }, 95 | "engines": { 96 | "node": ">= 12.0.0" 97 | }, 98 | "peerDependencies": { 99 | "rollup": "^2.68.0" 100 | } 101 | }, 102 | "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { 103 | "version": "2.0.2", 104 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 105 | "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", 106 | "dev": true 107 | }, 108 | "node_modules/@rollup/plugin-node-resolve": { 109 | "version": "14.0.1", 110 | "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-14.0.1.tgz", 111 | "integrity": "sha512-YvSs0ev00QWTQS8u+yaCJkIUPBgpmBsnzRJFvg8c2chbky85ZKoZtoNuRH0k9rjZT4xpgEPOiVTyeJTj1/iMdQ==", 112 | "dev": true, 113 | "dependencies": { 114 | "@rollup/pluginutils": "^3.1.0", 115 | "@types/resolve": "1.17.1", 116 | "deepmerge": "^4.2.2", 117 | "is-builtin-module": "^3.1.0", 118 | "is-module": "^1.0.0", 119 | "resolve": "^1.19.0" 120 | }, 121 | "engines": { 122 | "node": ">= 10.0.0" 123 | }, 124 | "peerDependencies": { 125 | "rollup": "^2.78.0" 126 | } 127 | }, 128 | "node_modules/@rollup/plugin-terser": { 129 | "version": "0.1.0", 130 | "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.1.0.tgz", 131 | "integrity": "sha512-N2KK+qUfHX2hBzVzM41UWGLrEmcjVC37spC8R3c9mt3oEDFKh3N2e12/lLp9aVSt86veR0TQiCNQXrm8C6aiUQ==", 132 | "dev": true, 133 | "dependencies": { 134 | "terser": "^5.15.1" 135 | }, 136 | "engines": { 137 | "node": ">=14.0.0" 138 | }, 139 | "peerDependencies": { 140 | "rollup": "^2.x || ^3.x" 141 | }, 142 | "peerDependenciesMeta": { 143 | "rollup": { 144 | "optional": true 145 | } 146 | } 147 | }, 148 | "node_modules/@rollup/pluginutils": { 149 | "version": "3.1.0", 150 | "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", 151 | "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", 152 | "dev": true, 153 | "dependencies": { 154 | "@types/estree": "0.0.39", 155 | "estree-walker": "^1.0.1", 156 | "picomatch": "^2.2.2" 157 | }, 158 | "engines": { 159 | "node": ">= 8.0.0" 160 | }, 161 | "peerDependencies": { 162 | "rollup": "^1.20.0||^2.0.0" 163 | } 164 | }, 165 | "node_modules/@types/estree": { 166 | "version": "0.0.39", 167 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", 168 | "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", 169 | "dev": true 170 | }, 171 | "node_modules/@types/node": { 172 | "version": "18.7.16", 173 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz", 174 | "integrity": "sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg==", 175 | "dev": true 176 | }, 177 | "node_modules/@types/resolve": { 178 | "version": "1.17.1", 179 | "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", 180 | "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", 181 | "dev": true, 182 | "dependencies": { 183 | "@types/node": "*" 184 | } 185 | }, 186 | "node_modules/acorn": { 187 | "version": "8.8.1", 188 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", 189 | "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", 190 | "dev": true, 191 | "bin": { 192 | "acorn": "bin/acorn" 193 | }, 194 | "engines": { 195 | "node": ">=0.4.0" 196 | } 197 | }, 198 | "node_modules/balanced-match": { 199 | "version": "1.0.2", 200 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 201 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 202 | "dev": true 203 | }, 204 | "node_modules/brace-expansion": { 205 | "version": "1.1.11", 206 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 207 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 208 | "dev": true, 209 | "dependencies": { 210 | "balanced-match": "^1.0.0", 211 | "concat-map": "0.0.1" 212 | } 213 | }, 214 | "node_modules/buffer-from": { 215 | "version": "1.1.2", 216 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 217 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 218 | "dev": true 219 | }, 220 | "node_modules/builtin-modules": { 221 | "version": "3.3.0", 222 | "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", 223 | "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", 224 | "dev": true, 225 | "engines": { 226 | "node": ">=6" 227 | }, 228 | "funding": { 229 | "url": "https://github.com/sponsors/sindresorhus" 230 | } 231 | }, 232 | "node_modules/commander": { 233 | "version": "2.20.3", 234 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", 235 | "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", 236 | "dev": true 237 | }, 238 | "node_modules/commondir": { 239 | "version": "1.0.1", 240 | "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", 241 | "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", 242 | "dev": true 243 | }, 244 | "node_modules/concat-map": { 245 | "version": "0.0.1", 246 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 247 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 248 | "dev": true 249 | }, 250 | "node_modules/deepmerge": { 251 | "version": "4.2.2", 252 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", 253 | "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", 254 | "dev": true, 255 | "engines": { 256 | "node": ">=0.10.0" 257 | } 258 | }, 259 | "node_modules/estree-walker": { 260 | "version": "1.0.1", 261 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", 262 | "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", 263 | "dev": true 264 | }, 265 | "node_modules/fs.realpath": { 266 | "version": "1.0.0", 267 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 268 | "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", 269 | "dev": true 270 | }, 271 | "node_modules/fsevents": { 272 | "version": "2.3.2", 273 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 274 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 275 | "dev": true, 276 | "hasInstallScript": true, 277 | "optional": true, 278 | "os": [ 279 | "darwin" 280 | ], 281 | "engines": { 282 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 283 | } 284 | }, 285 | "node_modules/function-bind": { 286 | "version": "1.1.1", 287 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", 288 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", 289 | "dev": true 290 | }, 291 | "node_modules/glob": { 292 | "version": "7.2.3", 293 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 294 | "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 295 | "dev": true, 296 | "dependencies": { 297 | "fs.realpath": "^1.0.0", 298 | "inflight": "^1.0.4", 299 | "inherits": "2", 300 | "minimatch": "^3.1.1", 301 | "once": "^1.3.0", 302 | "path-is-absolute": "^1.0.0" 303 | }, 304 | "engines": { 305 | "node": "*" 306 | }, 307 | "funding": { 308 | "url": "https://github.com/sponsors/isaacs" 309 | } 310 | }, 311 | "node_modules/has": { 312 | "version": "1.0.3", 313 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", 314 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", 315 | "dev": true, 316 | "dependencies": { 317 | "function-bind": "^1.1.1" 318 | }, 319 | "engines": { 320 | "node": ">= 0.4.0" 321 | } 322 | }, 323 | "node_modules/inflight": { 324 | "version": "1.0.6", 325 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 326 | "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", 327 | "dev": true, 328 | "dependencies": { 329 | "once": "^1.3.0", 330 | "wrappy": "1" 331 | } 332 | }, 333 | "node_modules/inherits": { 334 | "version": "2.0.4", 335 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 336 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 337 | "dev": true 338 | }, 339 | "node_modules/is-builtin-module": { 340 | "version": "3.2.0", 341 | "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.0.tgz", 342 | "integrity": "sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==", 343 | "dev": true, 344 | "dependencies": { 345 | "builtin-modules": "^3.3.0" 346 | }, 347 | "engines": { 348 | "node": ">=6" 349 | }, 350 | "funding": { 351 | "url": "https://github.com/sponsors/sindresorhus" 352 | } 353 | }, 354 | "node_modules/is-core-module": { 355 | "version": "2.10.0", 356 | "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", 357 | "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", 358 | "dev": true, 359 | "dependencies": { 360 | "has": "^1.0.3" 361 | }, 362 | "funding": { 363 | "url": "https://github.com/sponsors/ljharb" 364 | } 365 | }, 366 | "node_modules/is-module": { 367 | "version": "1.0.0", 368 | "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", 369 | "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", 370 | "dev": true 371 | }, 372 | "node_modules/is-reference": { 373 | "version": "1.2.1", 374 | "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", 375 | "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", 376 | "dev": true, 377 | "dependencies": { 378 | "@types/estree": "*" 379 | } 380 | }, 381 | "node_modules/magic-string": { 382 | "version": "0.25.9", 383 | "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", 384 | "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", 385 | "dev": true, 386 | "dependencies": { 387 | "sourcemap-codec": "^1.4.8" 388 | } 389 | }, 390 | "node_modules/minimatch": { 391 | "version": "3.1.2", 392 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 393 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 394 | "dev": true, 395 | "dependencies": { 396 | "brace-expansion": "^1.1.7" 397 | }, 398 | "engines": { 399 | "node": "*" 400 | } 401 | }, 402 | "node_modules/once": { 403 | "version": "1.4.0", 404 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 405 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 406 | "dev": true, 407 | "dependencies": { 408 | "wrappy": "1" 409 | } 410 | }, 411 | "node_modules/path-is-absolute": { 412 | "version": "1.0.1", 413 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 414 | "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 415 | "dev": true, 416 | "engines": { 417 | "node": ">=0.10.0" 418 | } 419 | }, 420 | "node_modules/path-parse": { 421 | "version": "1.0.7", 422 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 423 | "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 424 | "dev": true 425 | }, 426 | "node_modules/phoenix": { 427 | "version": "1.6.11", 428 | "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.6.11.tgz", 429 | "integrity": "sha512-z/MSg9yY20JhTSj6tKmOYE9p+PuU+FVstYtgBfIZPGNLKhSuV9Zcs9LLLKWeiJ9EUzaXS/QeO8Po4+jJnyNfMw==" 430 | }, 431 | "node_modules/phoenix_live_view": { 432 | "version": "0.17.11", 433 | "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.17.11.tgz", 434 | "integrity": "sha512-OL0yCzPeV2hjwJIjLWkNDIJ3D+pGhprbIK/s2Y0CImggq0t0TcueNjTkKnTu0wnMalH0EmQXt9R024XDjiTtiQ==" 435 | }, 436 | "node_modules/picomatch": { 437 | "version": "2.3.1", 438 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 439 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 440 | "dev": true, 441 | "engines": { 442 | "node": ">=8.6" 443 | }, 444 | "funding": { 445 | "url": "https://github.com/sponsors/jonschlinkert" 446 | } 447 | }, 448 | "node_modules/resolve": { 449 | "version": "1.22.1", 450 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", 451 | "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", 452 | "dev": true, 453 | "dependencies": { 454 | "is-core-module": "^2.9.0", 455 | "path-parse": "^1.0.7", 456 | "supports-preserve-symlinks-flag": "^1.0.0" 457 | }, 458 | "bin": { 459 | "resolve": "bin/resolve" 460 | }, 461 | "funding": { 462 | "url": "https://github.com/sponsors/ljharb" 463 | } 464 | }, 465 | "node_modules/rollup": { 466 | "version": "2.79.0", 467 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.0.tgz", 468 | "integrity": "sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA==", 469 | "dev": true, 470 | "bin": { 471 | "rollup": "dist/bin/rollup" 472 | }, 473 | "engines": { 474 | "node": ">=10.0.0" 475 | }, 476 | "optionalDependencies": { 477 | "fsevents": "~2.3.2" 478 | } 479 | }, 480 | "node_modules/rollup-plugin-inject-process-env": { 481 | "version": "1.3.1", 482 | "resolved": "https://registry.npmjs.org/rollup-plugin-inject-process-env/-/rollup-plugin-inject-process-env-1.3.1.tgz", 483 | "integrity": "sha512-kKDoL30IZr0wxbNVJjq+OS92RJSKRbKV6B5eNW4q3mZTFqoWDh6lHy+mPDYuuGuERFNKXkG+AKxvYqC9+DRpKQ==", 484 | "dev": true, 485 | "dependencies": { 486 | "magic-string": "^0.25.7" 487 | } 488 | }, 489 | "node_modules/source-map": { 490 | "version": "0.6.1", 491 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 492 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 493 | "dev": true, 494 | "engines": { 495 | "node": ">=0.10.0" 496 | } 497 | }, 498 | "node_modules/source-map-support": { 499 | "version": "0.5.21", 500 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 501 | "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 502 | "dev": true, 503 | "dependencies": { 504 | "buffer-from": "^1.0.0", 505 | "source-map": "^0.6.0" 506 | } 507 | }, 508 | "node_modules/sourcemap-codec": { 509 | "version": "1.4.8", 510 | "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", 511 | "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", 512 | "dev": true 513 | }, 514 | "node_modules/supports-preserve-symlinks-flag": { 515 | "version": "1.0.0", 516 | "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 517 | "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 518 | "dev": true, 519 | "engines": { 520 | "node": ">= 0.4" 521 | }, 522 | "funding": { 523 | "url": "https://github.com/sponsors/ljharb" 524 | } 525 | }, 526 | "node_modules/terser": { 527 | "version": "5.15.1", 528 | "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.1.tgz", 529 | "integrity": "sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==", 530 | "dev": true, 531 | "dependencies": { 532 | "@jridgewell/source-map": "^0.3.2", 533 | "acorn": "^8.5.0", 534 | "commander": "^2.20.0", 535 | "source-map-support": "~0.5.20" 536 | }, 537 | "bin": { 538 | "terser": "bin/terser" 539 | }, 540 | "engines": { 541 | "node": ">=10" 542 | } 543 | }, 544 | "node_modules/topbar": { 545 | "version": "1.0.1", 546 | "resolved": "https://registry.npmjs.org/topbar/-/topbar-1.0.1.tgz", 547 | "integrity": "sha512-HZqQSMBiG29vcjOrqKCM9iGY/h69G5gQH7ae83ZCPz5uPmbQKwK0sMEqzVDBiu64tWHJ+kk9NApECrF+FAAvRA==" 548 | }, 549 | "node_modules/wrappy": { 550 | "version": "1.0.2", 551 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 552 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 553 | "dev": true 554 | } 555 | }, 556 | "dependencies": { 557 | "@jridgewell/gen-mapping": { 558 | "version": "0.3.2", 559 | "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", 560 | "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", 561 | "dev": true, 562 | "requires": { 563 | "@jridgewell/set-array": "^1.0.1", 564 | "@jridgewell/sourcemap-codec": "^1.4.10", 565 | "@jridgewell/trace-mapping": "^0.3.9" 566 | } 567 | }, 568 | "@jridgewell/resolve-uri": { 569 | "version": "3.1.0", 570 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", 571 | "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", 572 | "dev": true 573 | }, 574 | "@jridgewell/set-array": { 575 | "version": "1.1.2", 576 | "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", 577 | "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", 578 | "dev": true 579 | }, 580 | "@jridgewell/source-map": { 581 | "version": "0.3.2", 582 | "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", 583 | "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", 584 | "dev": true, 585 | "requires": { 586 | "@jridgewell/gen-mapping": "^0.3.0", 587 | "@jridgewell/trace-mapping": "^0.3.9" 588 | } 589 | }, 590 | "@jridgewell/sourcemap-codec": { 591 | "version": "1.4.14", 592 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", 593 | "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", 594 | "dev": true 595 | }, 596 | "@jridgewell/trace-mapping": { 597 | "version": "0.3.17", 598 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", 599 | "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", 600 | "dev": true, 601 | "requires": { 602 | "@jridgewell/resolve-uri": "3.1.0", 603 | "@jridgewell/sourcemap-codec": "1.4.14" 604 | } 605 | }, 606 | "@rollup/plugin-commonjs": { 607 | "version": "22.0.2", 608 | "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", 609 | "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", 610 | "dev": true, 611 | "requires": { 612 | "@rollup/pluginutils": "^3.1.0", 613 | "commondir": "^1.0.1", 614 | "estree-walker": "^2.0.1", 615 | "glob": "^7.1.6", 616 | "is-reference": "^1.2.1", 617 | "magic-string": "^0.25.7", 618 | "resolve": "^1.17.0" 619 | }, 620 | "dependencies": { 621 | "estree-walker": { 622 | "version": "2.0.2", 623 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 624 | "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", 625 | "dev": true 626 | } 627 | } 628 | }, 629 | "@rollup/plugin-node-resolve": { 630 | "version": "14.0.1", 631 | "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-14.0.1.tgz", 632 | "integrity": "sha512-YvSs0ev00QWTQS8u+yaCJkIUPBgpmBsnzRJFvg8c2chbky85ZKoZtoNuRH0k9rjZT4xpgEPOiVTyeJTj1/iMdQ==", 633 | "dev": true, 634 | "requires": { 635 | "@rollup/pluginutils": "^3.1.0", 636 | "@types/resolve": "1.17.1", 637 | "deepmerge": "^4.2.2", 638 | "is-builtin-module": "^3.1.0", 639 | "is-module": "^1.0.0", 640 | "resolve": "^1.19.0" 641 | } 642 | }, 643 | "@rollup/plugin-terser": { 644 | "version": "0.1.0", 645 | "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.1.0.tgz", 646 | "integrity": "sha512-N2KK+qUfHX2hBzVzM41UWGLrEmcjVC37spC8R3c9mt3oEDFKh3N2e12/lLp9aVSt86veR0TQiCNQXrm8C6aiUQ==", 647 | "dev": true, 648 | "requires": { 649 | "terser": "^5.15.1" 650 | } 651 | }, 652 | "@rollup/pluginutils": { 653 | "version": "3.1.0", 654 | "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", 655 | "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", 656 | "dev": true, 657 | "requires": { 658 | "@types/estree": "0.0.39", 659 | "estree-walker": "^1.0.1", 660 | "picomatch": "^2.2.2" 661 | } 662 | }, 663 | "@types/estree": { 664 | "version": "0.0.39", 665 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", 666 | "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", 667 | "dev": true 668 | }, 669 | "@types/node": { 670 | "version": "18.7.16", 671 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz", 672 | "integrity": "sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg==", 673 | "dev": true 674 | }, 675 | "@types/resolve": { 676 | "version": "1.17.1", 677 | "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", 678 | "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", 679 | "dev": true, 680 | "requires": { 681 | "@types/node": "*" 682 | } 683 | }, 684 | "acorn": { 685 | "version": "8.8.1", 686 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", 687 | "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", 688 | "dev": true 689 | }, 690 | "balanced-match": { 691 | "version": "1.0.2", 692 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 693 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 694 | "dev": true 695 | }, 696 | "brace-expansion": { 697 | "version": "1.1.11", 698 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 699 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 700 | "dev": true, 701 | "requires": { 702 | "balanced-match": "^1.0.0", 703 | "concat-map": "0.0.1" 704 | } 705 | }, 706 | "buffer-from": { 707 | "version": "1.1.2", 708 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 709 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 710 | "dev": true 711 | }, 712 | "builtin-modules": { 713 | "version": "3.3.0", 714 | "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", 715 | "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", 716 | "dev": true 717 | }, 718 | "commander": { 719 | "version": "2.20.3", 720 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", 721 | "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", 722 | "dev": true 723 | }, 724 | "commondir": { 725 | "version": "1.0.1", 726 | "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", 727 | "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", 728 | "dev": true 729 | }, 730 | "concat-map": { 731 | "version": "0.0.1", 732 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 733 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 734 | "dev": true 735 | }, 736 | "deepmerge": { 737 | "version": "4.2.2", 738 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", 739 | "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", 740 | "dev": true 741 | }, 742 | "estree-walker": { 743 | "version": "1.0.1", 744 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", 745 | "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", 746 | "dev": true 747 | }, 748 | "fs.realpath": { 749 | "version": "1.0.0", 750 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 751 | "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", 752 | "dev": true 753 | }, 754 | "fsevents": { 755 | "version": "2.3.2", 756 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 757 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 758 | "dev": true, 759 | "optional": true 760 | }, 761 | "function-bind": { 762 | "version": "1.1.1", 763 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", 764 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", 765 | "dev": true 766 | }, 767 | "glob": { 768 | "version": "7.2.3", 769 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 770 | "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 771 | "dev": true, 772 | "requires": { 773 | "fs.realpath": "^1.0.0", 774 | "inflight": "^1.0.4", 775 | "inherits": "2", 776 | "minimatch": "^3.1.1", 777 | "once": "^1.3.0", 778 | "path-is-absolute": "^1.0.0" 779 | } 780 | }, 781 | "has": { 782 | "version": "1.0.3", 783 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", 784 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", 785 | "dev": true, 786 | "requires": { 787 | "function-bind": "^1.1.1" 788 | } 789 | }, 790 | "inflight": { 791 | "version": "1.0.6", 792 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 793 | "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", 794 | "dev": true, 795 | "requires": { 796 | "once": "^1.3.0", 797 | "wrappy": "1" 798 | } 799 | }, 800 | "inherits": { 801 | "version": "2.0.4", 802 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 803 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 804 | "dev": true 805 | }, 806 | "is-builtin-module": { 807 | "version": "3.2.0", 808 | "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.0.tgz", 809 | "integrity": "sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==", 810 | "dev": true, 811 | "requires": { 812 | "builtin-modules": "^3.3.0" 813 | } 814 | }, 815 | "is-core-module": { 816 | "version": "2.10.0", 817 | "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", 818 | "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", 819 | "dev": true, 820 | "requires": { 821 | "has": "^1.0.3" 822 | } 823 | }, 824 | "is-module": { 825 | "version": "1.0.0", 826 | "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", 827 | "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", 828 | "dev": true 829 | }, 830 | "is-reference": { 831 | "version": "1.2.1", 832 | "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", 833 | "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", 834 | "dev": true, 835 | "requires": { 836 | "@types/estree": "*" 837 | } 838 | }, 839 | "magic-string": { 840 | "version": "0.25.9", 841 | "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", 842 | "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", 843 | "dev": true, 844 | "requires": { 845 | "sourcemap-codec": "^1.4.8" 846 | } 847 | }, 848 | "minimatch": { 849 | "version": "3.1.2", 850 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 851 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 852 | "dev": true, 853 | "requires": { 854 | "brace-expansion": "^1.1.7" 855 | } 856 | }, 857 | "once": { 858 | "version": "1.4.0", 859 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 860 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 861 | "dev": true, 862 | "requires": { 863 | "wrappy": "1" 864 | } 865 | }, 866 | "path-is-absolute": { 867 | "version": "1.0.1", 868 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 869 | "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 870 | "dev": true 871 | }, 872 | "path-parse": { 873 | "version": "1.0.7", 874 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 875 | "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 876 | "dev": true 877 | }, 878 | "phoenix": { 879 | "version": "1.6.11", 880 | "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.6.11.tgz", 881 | "integrity": "sha512-z/MSg9yY20JhTSj6tKmOYE9p+PuU+FVstYtgBfIZPGNLKhSuV9Zcs9LLLKWeiJ9EUzaXS/QeO8Po4+jJnyNfMw==" 882 | }, 883 | "phoenix_live_view": { 884 | "version": "0.17.11", 885 | "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.17.11.tgz", 886 | "integrity": "sha512-OL0yCzPeV2hjwJIjLWkNDIJ3D+pGhprbIK/s2Y0CImggq0t0TcueNjTkKnTu0wnMalH0EmQXt9R024XDjiTtiQ==" 887 | }, 888 | "picomatch": { 889 | "version": "2.3.1", 890 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 891 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 892 | "dev": true 893 | }, 894 | "resolve": { 895 | "version": "1.22.1", 896 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", 897 | "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", 898 | "dev": true, 899 | "requires": { 900 | "is-core-module": "^2.9.0", 901 | "path-parse": "^1.0.7", 902 | "supports-preserve-symlinks-flag": "^1.0.0" 903 | } 904 | }, 905 | "rollup": { 906 | "version": "2.79.0", 907 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.0.tgz", 908 | "integrity": "sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA==", 909 | "dev": true, 910 | "requires": { 911 | "fsevents": "~2.3.2" 912 | } 913 | }, 914 | "rollup-plugin-inject-process-env": { 915 | "version": "1.3.1", 916 | "resolved": "https://registry.npmjs.org/rollup-plugin-inject-process-env/-/rollup-plugin-inject-process-env-1.3.1.tgz", 917 | "integrity": "sha512-kKDoL30IZr0wxbNVJjq+OS92RJSKRbKV6B5eNW4q3mZTFqoWDh6lHy+mPDYuuGuERFNKXkG+AKxvYqC9+DRpKQ==", 918 | "dev": true, 919 | "requires": { 920 | "magic-string": "^0.25.7" 921 | } 922 | }, 923 | "source-map": { 924 | "version": "0.6.1", 925 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 926 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 927 | "dev": true 928 | }, 929 | "source-map-support": { 930 | "version": "0.5.21", 931 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 932 | "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 933 | "dev": true, 934 | "requires": { 935 | "buffer-from": "^1.0.0", 936 | "source-map": "^0.6.0" 937 | } 938 | }, 939 | "sourcemap-codec": { 940 | "version": "1.4.8", 941 | "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", 942 | "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", 943 | "dev": true 944 | }, 945 | "supports-preserve-symlinks-flag": { 946 | "version": "1.0.0", 947 | "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 948 | "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 949 | "dev": true 950 | }, 951 | "terser": { 952 | "version": "5.15.1", 953 | "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.1.tgz", 954 | "integrity": "sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==", 955 | "dev": true, 956 | "requires": { 957 | "@jridgewell/source-map": "^0.3.2", 958 | "acorn": "^8.5.0", 959 | "commander": "^2.20.0", 960 | "source-map-support": "~0.5.20" 961 | } 962 | }, 963 | "topbar": { 964 | "version": "1.0.1", 965 | "resolved": "https://registry.npmjs.org/topbar/-/topbar-1.0.1.tgz", 966 | "integrity": "sha512-HZqQSMBiG29vcjOrqKCM9iGY/h69G5gQH7ae83ZCPz5uPmbQKwK0sMEqzVDBiu64tWHJ+kk9NApECrF+FAAvRA==" 967 | }, 968 | "wrappy": { 969 | "version": "1.0.2", 970 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 971 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 972 | "dev": true 973 | } 974 | } 975 | } 976 | -------------------------------------------------------------------------------- /src/rendered/builder.rs: -------------------------------------------------------------------------------- 1 | //! Builder to build [`Rendered`], used by the `html!` macro. 2 | 3 | use slotmap::{new_key_type, SlotMap}; 4 | 5 | use super::dynamic::DynamicList; 6 | use super::{Dynamic, DynamicItems, Dynamics, Rendered, RenderedListItem}; 7 | 8 | new_key_type! { struct NodeId; } 9 | 10 | /// Rendered builder, used by the `html!` macro. 11 | #[derive(Debug)] 12 | pub struct RenderedBuilder { 13 | nodes: SlotMap, 14 | last_node: NodeId, 15 | } 16 | 17 | #[derive(Debug)] 18 | struct Node { 19 | parent: NodeId, 20 | value: NodeValue, 21 | } 22 | 23 | #[derive(Debug)] 24 | enum NodeValue { 25 | Items(ItemsNode), 26 | List(ListNode), 27 | Nested(Rendered), 28 | } 29 | 30 | #[derive(Debug, Default)] 31 | struct ItemsNode { 32 | statics: Vec, 33 | dynamics: Vec, 34 | templates: Vec>, 35 | } 36 | 37 | #[derive(Debug)] 38 | struct ListNode { 39 | statics: Vec, 40 | dynamics: Vec>, 41 | iteration: usize, 42 | } 43 | 44 | #[derive(Debug)] 45 | enum DynamicNode { 46 | String(String), 47 | Nested(NodeId), 48 | } 49 | 50 | impl RenderedBuilder { 51 | /// Creates a new [`RenderedBuilder`]. 52 | pub fn new() -> Self { 53 | let mut nodes = SlotMap::with_key(); 54 | let last_node = nodes.insert(Node::new( 55 | NodeId::default(), 56 | NodeValue::Items(ItemsNode::default()), 57 | )); 58 | RenderedBuilder { nodes, last_node } 59 | } 60 | 61 | /// Builds into a [`Rendered`]. 62 | pub fn build(mut self) -> Rendered { 63 | let root = self.nodes.remove(self.last_node).unwrap(); 64 | root.build(&mut self) 65 | } 66 | 67 | /// Pushes a [`Rendered`] to be nested. 68 | pub fn push_nested(&mut self, other: Rendered) { 69 | let parent = self.parent_of(self.last_node).unwrap(); 70 | let id = self 71 | .nodes 72 | .insert(Node::new(parent, NodeValue::Nested(other))); 73 | let last_node = self.last_node_mut(); 74 | match &mut last_node.value { 75 | NodeValue::Items(items) => { 76 | items.statics.push(String::new()); 77 | items.dynamics.push(DynamicNode::Nested(id)); 78 | } 79 | NodeValue::List(_) => { 80 | self.nodes.remove(id); 81 | todo!() 82 | } 83 | NodeValue::Nested(_) => { 84 | self.nodes.remove(id); 85 | todo!() 86 | } 87 | } 88 | } 89 | 90 | /// Pushes a static string. 91 | pub fn push_static(&mut self, s: &str) { 92 | self.last_node_mut().push_static(s) 93 | } 94 | 95 | /// Pushes a dynamic string. 96 | pub fn push_dynamic(&mut self, s: String) { 97 | self.last_node_mut().push_dynamic(s) 98 | } 99 | 100 | /// Pushes an if frame. 101 | pub fn push_if_frame(&mut self) { 102 | self.push_dynamic_node(NodeValue::Items(ItemsNode::default())); 103 | } 104 | 105 | /// Pushes a for loop frame. 106 | pub fn push_for_frame(&mut self) { 107 | self.push_dynamic_node(NodeValue::List(ListNode::default())); 108 | } 109 | 110 | /// Pushes an item frame in a for loop. 111 | pub fn push_for_item(&mut self) { 112 | let last_node = self.last_node_mut(); 113 | match &mut last_node.value { 114 | NodeValue::Items(_) => { 115 | panic!("push_for_item cannot be called outside the context of a for loop"); 116 | } 117 | NodeValue::List(list) => { 118 | list.iteration = list.iteration.wrapping_add(1); // First iteration will be 0 119 | list.dynamics.push(vec![]); 120 | } 121 | NodeValue::Nested(_) => todo!(), 122 | } 123 | } 124 | 125 | /// Pops an item from the for loop. 126 | pub fn pop_for_item(&mut self) {} 127 | 128 | /// Pops a frame. 129 | pub fn pop_frame(&mut self) { 130 | if let Some(parent_id) = self.parent_of(self.last_node) { 131 | self.last_node = parent_id; 132 | } 133 | } 134 | 135 | fn last_node_mut(&mut self) -> &mut Node { 136 | self.nodes.get_mut(self.last_node).unwrap() 137 | } 138 | 139 | fn parent_of(&mut self, id: NodeId) -> Option { 140 | self.nodes.get(id).map(|node| node.parent) 141 | } 142 | 143 | fn push_dynamic_node(&mut self, value: NodeValue) { 144 | let id = self.nodes.insert(Node::new(self.last_node, value)); 145 | let last_node = self.last_node_mut(); 146 | match &mut last_node.value { 147 | NodeValue::Items(items) => { 148 | items.dynamics.push(DynamicNode::Nested(id)); 149 | items.statics.push(String::new()); 150 | } 151 | NodeValue::List(list) => match list.dynamics.last_mut() { 152 | Some(last_list) => last_list.push(DynamicNode::Nested(id)), 153 | None => { 154 | list.dynamics.push(vec![DynamicNode::Nested(id)]); 155 | list.statics.push(String::new()); 156 | } 157 | }, 158 | NodeValue::Nested(_) => todo!(), 159 | } 160 | self.last_node = id; 161 | } 162 | } 163 | 164 | impl Default for RenderedBuilder { 165 | fn default() -> Self { 166 | Self::new() 167 | } 168 | } 169 | 170 | impl Node { 171 | fn new(parent: NodeId, value: NodeValue) -> Self { 172 | Node { parent, value } 173 | } 174 | 175 | fn build(self, tree: &mut RenderedBuilder) -> Rendered { 176 | match self.value { 177 | NodeValue::Items(items) => items.build(tree), 178 | NodeValue::List(list) => list.build(tree), 179 | NodeValue::Nested(nested) => nested, 180 | } 181 | } 182 | 183 | fn push_static(&mut self, s: &str) { 184 | match &mut self.value { 185 | NodeValue::Items(items) => items.push_static(s), 186 | NodeValue::List(list) => list.push_static(s), 187 | NodeValue::Nested(_) => todo!(), 188 | } 189 | } 190 | 191 | fn push_dynamic(&mut self, s: String) { 192 | match &mut self.value { 193 | NodeValue::Items(items) => items.push_dynamic(s), 194 | NodeValue::List(list) => list.push_dynamic(s), 195 | NodeValue::Nested(_) => todo!(), 196 | } 197 | } 198 | } 199 | 200 | impl ItemsNode { 201 | fn build(mut self, tree: &mut RenderedBuilder) -> Rendered { 202 | let dynamics: Vec<_> = self 203 | .dynamics 204 | .into_iter() 205 | .map(|dynamic| dynamic.build_items(tree)) 206 | .collect(); 207 | 208 | insert_empty_strings(&mut self.statics, dynamics.len()); 209 | 210 | Rendered { 211 | statics: self.statics, 212 | dynamics: Dynamics::Items(DynamicItems(dynamics)), 213 | templates: self.templates, 214 | } 215 | } 216 | 217 | fn push_static(&mut self, s: &str) { 218 | push_or_extend_static_string(&mut self.statics, self.dynamics.len(), s); 219 | } 220 | 221 | fn push_dynamic(&mut self, s: String) { 222 | if self.statics.is_empty() { 223 | self.statics.push(String::new()); 224 | } 225 | 226 | self.dynamics.push(DynamicNode::String(s)); 227 | } 228 | } 229 | 230 | impl ListNode { 231 | fn build(self, tree: &mut RenderedBuilder) -> Rendered { 232 | let mut templates = vec![]; 233 | 234 | let dynamics: Vec> = self 235 | .dynamics 236 | .into_iter() 237 | .map(|dynamics| { 238 | dynamics 239 | .into_iter() 240 | .map(|dynamic| dynamic.build_list(tree, &mut templates)) 241 | .collect() 242 | }) 243 | .collect(); 244 | 245 | Rendered { 246 | statics: self.statics, 247 | dynamics: Dynamics::List(DynamicList(dynamics)), 248 | templates, 249 | } 250 | } 251 | 252 | fn push_static(&mut self, s: &str) { 253 | if self.iteration == 0 { 254 | let dynamics_len = self.dynamics.first().map(|first| first.len()).unwrap_or(0); 255 | push_or_extend_static_string(&mut self.statics, dynamics_len, s); 256 | } 257 | } 258 | 259 | fn push_dynamic(&mut self, s: String) { 260 | self.dynamics 261 | .last_mut() 262 | .unwrap() 263 | .push(DynamicNode::String(s)); 264 | } 265 | } 266 | 267 | impl Default for ListNode { 268 | fn default() -> Self { 269 | Self { 270 | statics: Default::default(), 271 | dynamics: Default::default(), 272 | iteration: usize::MAX, 273 | } 274 | } 275 | } 276 | 277 | impl DynamicNode { 278 | fn build_items(self, tree: &mut RenderedBuilder) -> Dynamic { 279 | match self { 280 | DynamicNode::String(s) => Dynamic::String(s), 281 | DynamicNode::Nested(id) => { 282 | let mut nested = tree.nodes.remove(id).unwrap().build(tree); 283 | match nested.dynamics { 284 | Dynamics::Items(ref items) => { 285 | if nested.statics.is_empty() && items.is_empty() { 286 | Dynamic::String(String::new()) 287 | } else { 288 | insert_empty_strings(&mut nested.statics, items.len()); 289 | Dynamic::Nested(nested) 290 | } 291 | } 292 | Dynamics::List(list) => { 293 | let dynamics_len = list.first().map(|first| first.len()).unwrap_or(0); 294 | if nested.statics.is_empty() && dynamics_len == 0 { 295 | Dynamic::String(String::new()) 296 | } else { 297 | insert_empty_strings(&mut nested.statics, dynamics_len); 298 | 299 | Dynamic::Nested(Rendered { 300 | statics: nested.statics, 301 | dynamics: Dynamics::List(list), 302 | templates: nested.templates, 303 | }) 304 | } 305 | } 306 | } 307 | } 308 | } 309 | } 310 | 311 | fn build_list( 312 | self, 313 | tree: &mut RenderedBuilder, 314 | templates: &mut Vec>, 315 | ) -> Dynamic { 316 | match self { 317 | DynamicNode::String(s) => Dynamic::String(s), 318 | DynamicNode::Nested(id) => { 319 | let node = tree.nodes.remove(id).unwrap(); 320 | match node.value { 321 | NodeValue::Items(mut items) => { 322 | if items.statics.is_empty() && items.dynamics.is_empty() { 323 | Dynamic::String(String::new()) 324 | } else { 325 | let dynamics: Vec<_> = items 326 | .dynamics 327 | .into_iter() 328 | .map(|dynamic| dynamic.build_list(tree, templates)) 329 | .collect(); 330 | 331 | insert_empty_strings(&mut items.statics, dynamics.len()); 332 | let statics = templates 333 | .iter() 334 | .enumerate() 335 | .find_map(|(i, template)| { 336 | if vecs_match(template, &items.statics) { 337 | Some(i) 338 | } else { 339 | None 340 | } 341 | }) 342 | .unwrap_or_else(|| { 343 | templates.push(items.statics); 344 | templates.len() - 1 345 | }); 346 | 347 | Dynamic::Nested(RenderedListItem { 348 | statics, 349 | dynamics: vec![Dynamics::List(DynamicList(vec![dynamics]))], 350 | }) 351 | } 352 | } 353 | NodeValue::List(list) => { 354 | let mut longest_dynamic = 0; 355 | let dynamics: Vec<_> = list 356 | .dynamics 357 | .into_iter() 358 | .map(|dynamics| { 359 | let dynamics: Vec<_> = dynamics 360 | .into_iter() 361 | .map(|dynamic| dynamic.build_list(tree, templates)) 362 | .collect(); 363 | longest_dynamic = longest_dynamic.max(dynamics.len()); 364 | Dynamics::List(DynamicList(vec![dynamics])) 365 | }) 366 | .collect(); 367 | 368 | let statics = templates 369 | .iter() 370 | .enumerate() 371 | .find_map(|(i, template)| { 372 | if vecs_match(template, &list.statics) { 373 | Some(i) 374 | } else { 375 | None 376 | } 377 | }) 378 | .unwrap_or_else(|| { 379 | templates.push(list.statics); 380 | templates.len() - 1 381 | }); 382 | insert_empty_strings(templates.last_mut().unwrap(), longest_dynamic); 383 | 384 | Dynamic::Nested(RenderedListItem { statics, dynamics }) 385 | } 386 | NodeValue::Nested(_) => todo!(), 387 | } 388 | } 389 | } 390 | } 391 | } 392 | 393 | fn insert_empty_strings(statics: &mut Vec, dynamics_len: usize) { 394 | if dynamics_len > 0 { 395 | let missing_empty_string_count = dynamics_len + 1 - statics.len(); 396 | for _ in 0..missing_empty_string_count { 397 | statics.push(String::new()); 398 | } 399 | } 400 | } 401 | 402 | fn push_or_extend_static_string(statics: &mut Vec, dynamics_len: usize, s: &str) { 403 | // If statics length is >= dynamics length, we should extend the previous static 404 | // string. 405 | let statics_len = statics.len(); 406 | match statics.last_mut() { 407 | Some(static_string) if statics_len > dynamics_len => static_string.push_str(s), 408 | _ => statics.push(s.to_string()), 409 | } 410 | } 411 | 412 | fn vecs_match(a: &Vec, b: &Vec) -> bool { 413 | let matching = a.iter().zip(b.iter()).filter(|&(a, b)| a == b).count(); 414 | matching == a.len() && matching == b.len() 415 | } 416 | 417 | #[cfg(test)] 418 | mod tests { 419 | use pretty_assertions::assert_eq; 420 | 421 | use crate::maud::DOCTYPE; 422 | use crate::rendered::dynamic::{Dynamic, DynamicItems, DynamicList, Dynamics}; 423 | use crate::rendered::{Rendered, RenderedListItem}; 424 | use crate::{self as submillisecond_live_view, html}; 425 | 426 | #[lunatic::test] 427 | fn basic() { 428 | let rendered = html! { 429 | p { "Hello, world!" } 430 | }; 431 | 432 | assert_eq!(rendered.statics, ["

Hello, world!

"]); 433 | assert_eq!(rendered.dynamics, Dynamics::Items(DynamicItems(vec![]))); 434 | assert!(rendered.templates.is_empty()); 435 | } 436 | 437 | #[lunatic::test] 438 | fn dynamic() { 439 | let rendered = html! { 440 | (DOCTYPE) 441 | a href={ ("hey") "/lambda-fairy/maud" } { 442 | "Hello, world!" 443 | } 444 | }; 445 | 446 | assert_eq!( 447 | rendered, 448 | Rendered { 449 | statics: vec![ 450 | "".to_string(), 451 | "Hello, world!".to_string() 453 | ], 454 | dynamics: Dynamics::Items(DynamicItems(vec![ 455 | Dynamic::String("".to_string()), 456 | Dynamic::String("hey".to_string()) 457 | ])), 458 | templates: vec![] 459 | } 460 | ); 461 | } 462 | 463 | #[lunatic::test] 464 | fn if_statement_false() { 465 | let logged_in = false; 466 | let rendered = html! { 467 | "Welcome " 468 | @if logged_in { 469 | "person" 470 | } 471 | "." 472 | }; 473 | 474 | assert_eq!( 475 | rendered, 476 | Rendered { 477 | statics: vec!["Welcome ".to_string(), ".".to_string()], 478 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String("".to_string())])), 479 | templates: vec![] 480 | } 481 | ); 482 | 483 | let logged_in = false; 484 | let rendered = html! { 485 | "Welcome " 486 | @if logged_in { 487 | (logged_in.to_string()) 488 | } 489 | "." 490 | }; 491 | 492 | assert_eq!( 493 | rendered, 494 | Rendered { 495 | statics: vec!["Welcome ".to_string(), ".".to_string()], 496 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String("".to_string())])), 497 | templates: vec![] 498 | } 499 | ); 500 | } 501 | 502 | #[lunatic::test] 503 | fn if_statement_true() { 504 | let logged_in = true; 505 | let rendered = html! { 506 | "Welcome " 507 | @if logged_in { 508 | "person" 509 | } 510 | "." 511 | }; 512 | 513 | assert_eq!( 514 | rendered, 515 | Rendered { 516 | statics: vec!["Welcome ".to_string(), ".".to_string()], 517 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 518 | statics: vec!["person".to_string()], 519 | dynamics: Dynamics::Items(DynamicItems(vec![])), 520 | templates: vec![], 521 | })])), 522 | templates: vec![], 523 | } 524 | ); 525 | 526 | let logged_in = true; 527 | let rendered = html! { 528 | "Welcome " 529 | @if logged_in { 530 | (logged_in.to_string()) 531 | } 532 | "." 533 | }; 534 | 535 | assert_eq!( 536 | rendered, 537 | Rendered { 538 | statics: vec!["Welcome ".to_string(), ".".to_string()], 539 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 540 | statics: vec!["".to_string(), "".to_string()], 541 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String( 542 | "true".to_string() 543 | )])), 544 | templates: vec![], 545 | })])), 546 | templates: vec![], 547 | } 548 | ); 549 | } 550 | 551 | #[lunatic::test] 552 | fn if_statement_let_some() { 553 | let user = Some("Bob"); 554 | let rendered = html! { 555 | "Welcome " 556 | @if let Some(user) = user { 557 | (user) 558 | } @else { 559 | "stranger" 560 | } 561 | }; 562 | 563 | assert_eq!( 564 | rendered, 565 | Rendered { 566 | statics: vec!["Welcome ".to_string(), "".to_string()], 567 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 568 | statics: vec!["".to_string(), "".to_string()], 569 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String( 570 | "Bob".to_string() 571 | )])), 572 | templates: vec![], 573 | })])), 574 | templates: vec![], 575 | } 576 | ); 577 | } 578 | 579 | #[lunatic::test] 580 | fn if_statement_let_none() { 581 | let user: Option<&str> = None; 582 | let rendered = html! { 583 | "Welcome " 584 | @if let Some(user) = user { 585 | (user) 586 | } @else { 587 | "stranger" 588 | } 589 | }; 590 | 591 | assert_eq!( 592 | rendered, 593 | Rendered { 594 | statics: vec!["Welcome ".to_string(), "".to_string()], 595 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 596 | statics: vec!["stranger".to_string()], 597 | dynamics: Dynamics::Items(DynamicItems(vec![])), 598 | templates: vec![], 599 | })])), 600 | templates: vec![], 601 | } 602 | ); 603 | } 604 | 605 | #[lunatic::test] 606 | fn if_statement_nested() { 607 | let render = |count: usize| { 608 | html! { 609 | @if count >= 1 { 610 | p { "Count is high" } 611 | @if count >= 2 { 612 | p { "Count is very high!" } 613 | } 614 | } 615 | } 616 | }; 617 | 618 | let rendered = render(0); 619 | 620 | assert_eq!( 621 | rendered, 622 | Rendered { 623 | statics: vec!["".to_string(), "".to_string()], 624 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String("".to_string())])), 625 | templates: vec![], 626 | } 627 | ); 628 | 629 | let rendered = render(1); 630 | 631 | assert_eq!( 632 | rendered, 633 | Rendered { 634 | statics: vec!["".to_string(), "".to_string()], 635 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 636 | statics: vec!["

Count is high

".to_string(), "".to_string()], 637 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String("".to_string())])), 638 | templates: vec![], 639 | })])), 640 | templates: vec![], 641 | } 642 | ); 643 | 644 | let rendered = render(2); 645 | 646 | assert_eq!( 647 | rendered, 648 | Rendered { 649 | statics: vec!["".to_string(), "".to_string()], 650 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 651 | statics: vec!["

Count is high

".to_string(), "".to_string()], 652 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 653 | statics: vec!["

Count is very high!

".to_string()], 654 | dynamics: Dynamics::Items(DynamicItems(vec![])), 655 | templates: vec![], 656 | })])), 657 | templates: vec![], 658 | })])), 659 | templates: vec![], 660 | } 661 | ); 662 | } 663 | 664 | #[lunatic::test] 665 | fn for_loop_empty() { 666 | #[allow(clippy::reversed_empty_ranges)] 667 | let rendered = html! { 668 | span { "Hello" } 669 | @for _ in 0..0 { 670 | span { "Hi!" } 671 | } 672 | span { "world" } 673 | }; 674 | 675 | assert_eq!( 676 | rendered, 677 | Rendered { 678 | statics: vec![ 679 | "Hello".to_string(), 680 | "world".to_string() 681 | ], 682 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::String("".to_string())])), 683 | templates: vec![], 684 | } 685 | ); 686 | } 687 | 688 | #[lunatic::test] 689 | fn for_loop_statics() { 690 | let rendered = html! { 691 | @for _ in 0..3 { 692 | span { "Hi!" } 693 | } 694 | }; 695 | 696 | assert_eq!( 697 | rendered, 698 | Rendered { 699 | statics: vec!["".to_string(), "".to_string()], 700 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 701 | statics: vec!["Hi!".to_string()], 702 | dynamics: Dynamics::List(DynamicList(vec![vec![], vec![], vec![]])), 703 | templates: vec![], 704 | })])), 705 | templates: vec![], 706 | } 707 | ); 708 | } 709 | 710 | #[lunatic::test] 711 | fn for_loop_dynamics() { 712 | let names = ["John", "Joe", "Jim"]; 713 | let rendered = html! { 714 | @for name in names { 715 | span { (name) } 716 | } 717 | }; 718 | 719 | assert_eq!( 720 | rendered, 721 | Rendered { 722 | statics: vec!["".to_string(), "".to_string()], 723 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 724 | statics: vec!["".to_string(), "".to_string()], 725 | dynamics: Dynamics::List(DynamicList(vec![ 726 | vec![Dynamic::String("John".to_string())], 727 | vec![Dynamic::String("Joe".to_string())], 728 | vec![Dynamic::String("Jim".to_string())], 729 | ])), 730 | templates: vec![], 731 | })])), 732 | templates: vec![], 733 | } 734 | ); 735 | 736 | let names = ["John", "Joe", "Jim"]; 737 | let rendered = html! { 738 | @for name in names { 739 | span class=(name) { (name) } 740 | } 741 | }; 742 | 743 | assert_eq!( 744 | rendered, 745 | Rendered { 746 | statics: vec!["".to_string(), "".to_string()], 747 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 748 | statics: vec![ 749 | "".to_string(), 751 | "".to_string() 752 | ], 753 | dynamics: Dynamics::List(DynamicList(vec![ 754 | vec![ 755 | Dynamic::String("John".to_string()), 756 | Dynamic::String("John".to_string()) 757 | ], 758 | vec![ 759 | Dynamic::String("Joe".to_string()), 760 | Dynamic::String("Joe".to_string()) 761 | ], 762 | vec![ 763 | Dynamic::String("Jim".to_string()), 764 | Dynamic::String("Jim".to_string()) 765 | ], 766 | ])), 767 | templates: vec![], 768 | })])), 769 | templates: vec![], 770 | } 771 | ); 772 | } 773 | 774 | #[lunatic::test] 775 | fn for_loop_multiple() { 776 | #[allow(clippy::reversed_empty_ranges)] 777 | let rendered = html! { 778 | span { "Hello" } 779 | @for _ in 0..2 { 780 | span { "A" } 781 | } 782 | @for _ in 0..0 { 783 | span { "B" } 784 | } 785 | span { "world" } 786 | }; 787 | 788 | assert_eq!( 789 | rendered, 790 | Rendered { 791 | statics: vec![ 792 | "Hello".to_string(), 793 | "".to_string(), 794 | "world".to_string() 795 | ], 796 | dynamics: Dynamics::Items(DynamicItems(vec![ 797 | Dynamic::Nested(Rendered { 798 | statics: vec!["A".to_string()], 799 | dynamics: Dynamics::List(DynamicList(vec![vec![], vec![]])), 800 | templates: vec![] 801 | }), 802 | Dynamic::String("".to_string()), 803 | ])), 804 | templates: vec![], 805 | } 806 | ); 807 | } 808 | 809 | #[lunatic::test] 810 | fn for_loop_nested() { 811 | let a = "Hello"; 812 | let b = "World"; 813 | let rendered = html! { 814 | @for foo in [[a, b]] { 815 | @for bar in foo { 816 | span { (bar) } 817 | } 818 | } 819 | }; 820 | 821 | assert_eq!( 822 | rendered, 823 | Rendered { 824 | statics: vec!["".to_string(), "".to_string()], 825 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 826 | statics: vec!["".to_string(), "".to_string()], 827 | dynamics: Dynamics::List(DynamicList(vec![vec![Dynamic::Nested( 828 | RenderedListItem { 829 | statics: 0, 830 | dynamics: vec![ 831 | Dynamics::List(DynamicList(vec![vec![Dynamic::String( 832 | "Hello".to_string() 833 | )]])), 834 | Dynamics::List(DynamicList(vec![vec![Dynamic::String( 835 | "World".to_string() 836 | )]])) 837 | ], 838 | }, 839 | )]])), 840 | templates: vec![vec!["".to_string(), "".to_string()]], 841 | })])), 842 | templates: vec![], 843 | } 844 | ); 845 | 846 | let rendered = html! { 847 | @for foo in [[a, b]] { 848 | @for bar in foo { 849 | span { (bar) } 850 | @if bar == "World" { 851 | div { "!!!" } 852 | } 853 | } 854 | } 855 | }; 856 | 857 | assert_eq!( 858 | rendered, 859 | Rendered { 860 | statics: vec!["".to_string(), "".to_string()], 861 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 862 | statics: vec!["".to_string(), "".to_string()], 863 | dynamics: Dynamics::List(DynamicList(vec![vec![Dynamic::Nested( 864 | RenderedListItem { 865 | statics: 1, 866 | dynamics: vec![ 867 | Dynamics::List(DynamicList(vec![vec![ 868 | Dynamic::String("Hello".to_string()), 869 | Dynamic::String("".to_string()) 870 | ]])), 871 | Dynamics::List(DynamicList(vec![vec![ 872 | Dynamic::String("World".to_string()), 873 | Dynamic::Nested(RenderedListItem { 874 | statics: 0, 875 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![]]))], 876 | }) 877 | ]])) 878 | ], 879 | }, 880 | )]])), 881 | templates: vec![ 882 | vec!["
!!!
".to_string()], 883 | vec!["".to_string(), "".to_string(), "".to_string()] 884 | ], 885 | })])), 886 | templates: vec![], 887 | } 888 | ); 889 | } 890 | 891 | #[lunatic::test] 892 | fn for_loop_with_if() { 893 | let names = ["John", "Joe", "Jim"]; 894 | let rendered = html! { 895 | @for name in names { 896 | span { "Welcome, " (name) "." } 897 | @if name == "Jim" { 898 | span { "You are a VIP, " (name.to_lowercase()) } 899 | } 900 | } 901 | }; 902 | 903 | assert_eq!( 904 | rendered, 905 | Rendered { 906 | statics: vec!["".to_string(), "".to_string()], 907 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 908 | statics: vec![ 909 | "Welcome, ".to_string(), 910 | ".".to_string(), 911 | "".to_string() 912 | ], 913 | dynamics: Dynamics::List(DynamicList(vec![ 914 | vec![ 915 | Dynamic::String("John".to_string()), 916 | Dynamic::String("".to_string()), 917 | ], 918 | vec![ 919 | Dynamic::String("Joe".to_string()), 920 | Dynamic::String("".to_string()), 921 | ], 922 | vec![ 923 | Dynamic::String("Jim".to_string()), 924 | Dynamic::Nested(RenderedListItem { 925 | statics: 0, 926 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 927 | Dynamic::String("jim".to_string()) 928 | ]]))], 929 | }) 930 | ], 931 | ])), 932 | templates: vec![vec![ 933 | "You are a VIP, ".to_string(), 934 | "".to_string() 935 | ]], 936 | })])), 937 | templates: vec![] 938 | } 939 | ); 940 | } 941 | 942 | #[lunatic::test] 943 | fn for_loop_with_multiple_ifs() { 944 | let names = ["John", "Joe", "Jim"]; 945 | let rendered = html! { 946 | @for name in names { 947 | span { "Welcome, " (name) "." } 948 | @if name == "Jim" { 949 | span { "You are a VIP, " (name.to_lowercase()) } 950 | @if name.ends_with('m') { 951 | span { (name) " ends with m" } 952 | } 953 | } 954 | } 955 | }; 956 | 957 | assert_eq!( 958 | rendered, 959 | Rendered { 960 | statics: vec!["".to_string(), "".to_string()], 961 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 962 | statics: vec![ 963 | "Welcome, ".to_string(), 964 | ".".to_string(), 965 | "".to_string() 966 | ], 967 | dynamics: Dynamics::List(DynamicList(vec![ 968 | vec![ 969 | Dynamic::String("John".to_string()), 970 | Dynamic::String("".to_string()), 971 | ], 972 | vec![ 973 | Dynamic::String("Joe".to_string()), 974 | Dynamic::String("".to_string()), 975 | ], 976 | vec![ 977 | Dynamic::String("Jim".to_string()), 978 | Dynamic::Nested(RenderedListItem { 979 | statics: 1, 980 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 981 | Dynamic::String("jim".to_string()), 982 | Dynamic::Nested(RenderedListItem { 983 | statics: 0, 984 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 985 | Dynamic::String("Jim".to_string()) 986 | ]]))], 987 | }) 988 | ]])),], 989 | }) 990 | ], 991 | ])), 992 | templates: vec![ 993 | vec!["".to_string(), " ends with m".to_string()], 994 | vec![ 995 | "You are a VIP, ".to_string(), 996 | "".to_string(), 997 | "".to_string() 998 | ], 999 | ], 1000 | })])), 1001 | templates: vec![], 1002 | } 1003 | ); 1004 | } 1005 | 1006 | #[lunatic::test] 1007 | fn for_loop_with_many_ifs() { 1008 | let names = ["John", "Joe", "Jim"]; 1009 | let rendered = html! { 1010 | @for name in names { 1011 | span { "Welcome, " (name) "." } 1012 | @if name == "Jim" || name == "Joe" { 1013 | span { "You are a VIP, " (name.to_lowercase()) } 1014 | @if name.ends_with('m') || name.ends_with('e') { 1015 | span { (name) " ends with m or e" } 1016 | } 1017 | } 1018 | } 1019 | }; 1020 | 1021 | assert_eq!( 1022 | rendered, 1023 | Rendered { 1024 | statics: vec!["".to_string(), "".to_string()], 1025 | dynamics: Dynamics::Items(DynamicItems(vec![Dynamic::Nested(Rendered { 1026 | statics: vec![ 1027 | "Welcome, ".to_string(), 1028 | ".".to_string(), 1029 | "".to_string() 1030 | ], 1031 | dynamics: Dynamics::List(DynamicList(vec![ 1032 | vec![ 1033 | Dynamic::String("John".to_string()), 1034 | Dynamic::String("".to_string()), 1035 | ], 1036 | vec![ 1037 | Dynamic::String("Joe".to_string()), 1038 | Dynamic::Nested(RenderedListItem { 1039 | statics: 1, 1040 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 1041 | Dynamic::String("joe".to_string()), 1042 | Dynamic::Nested(RenderedListItem { 1043 | statics: 0, 1044 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 1045 | Dynamic::String("Joe".to_string()) 1046 | ]]))], 1047 | }) 1048 | ]]))], 1049 | }), 1050 | ], 1051 | vec![ 1052 | Dynamic::String("Jim".to_string()), 1053 | Dynamic::Nested(RenderedListItem { 1054 | statics: 1, 1055 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 1056 | Dynamic::String("jim".to_string()), 1057 | Dynamic::Nested(RenderedListItem { 1058 | statics: 0, 1059 | dynamics: vec![Dynamics::List(DynamicList(vec![vec![ 1060 | Dynamic::String("Jim".to_string()) 1061 | ]]))], 1062 | }) 1063 | ]]))], 1064 | }), 1065 | ], 1066 | ])), 1067 | templates: vec![ 1068 | vec!["".to_string(), " ends with m or e".to_string()], 1069 | vec![ 1070 | "You are a VIP, ".to_string(), 1071 | "".to_string(), 1072 | "".to_string() 1073 | ], 1074 | ], 1075 | })])), 1076 | templates: vec![] 1077 | } 1078 | ); 1079 | } 1080 | } 1081 | --------------------------------------------------------------------------------