├── .gitignore ├── .tours └── htmx-rust-tera-template.tour ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── main.rs ├── static └── style.css └── templates ├── base.html ├── counter.html └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | ### Rust ### 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 8 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 9 | Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # MSVC Windows builds of rustc generate these, which store debugging information 15 | *.pdb 16 | -------------------------------------------------------------------------------- /.tours/htmx-rust-tera-template.tour: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/codetour-schema", 3 | "title": "Introduction to the template", 4 | "steps": [ 5 | { 6 | "file": "src/main.rs", 7 | "selection": { 8 | "start": { 9 | "line": 99, 10 | "character": 5 11 | }, 12 | "end": { 13 | "line": 99, 14 | "character": 25 15 | } 16 | }, 17 | "description": "Create a new server" 18 | }, 19 | { 20 | "file": "src/main.rs", 21 | "selection": { 22 | "start": { 23 | "line": 102, 24 | "character": 13 25 | }, 26 | "end": { 27 | "line": 104, 28 | "character": 16 29 | } 30 | }, 31 | "description": "Add app data. We will have a look at it later." 32 | }, 33 | { 34 | "file": "src/main.rs", 35 | "selection": { 36 | "start": { 37 | "line": 105, 38 | "character": 13 39 | }, 40 | "end": { 41 | "line": 108, 42 | "character": 38 43 | } 44 | }, 45 | "description": "Register the routes" 46 | }, 47 | { 48 | "file": "src/main.rs", 49 | "selection": { 50 | "start": { 51 | "line": 109, 52 | "character": 13 53 | }, 54 | "end": { 55 | "line": 110, 56 | "character": 89 57 | } 58 | }, 59 | "description": "Serve static files" 60 | }, 61 | { 62 | "file": "src/main.rs", 63 | "selection": { 64 | "start": { 65 | "line": 112, 66 | "character": 5 67 | }, 68 | "end": { 69 | "line": 114, 70 | "character": 13 71 | } 72 | }, 73 | "description": "Start the server on port 8080\n\n>> cargo run\n\n[Open in Browser](https://localhost:8080/)" 74 | }, 75 | { 76 | "file": "src/main.rs", 77 | "selection": { 78 | "start": { 79 | "line": 20, 80 | "character": 1 81 | }, 82 | "end": { 83 | "line": 23, 84 | "character": 2 85 | } 86 | }, 87 | "description": "## The App's State\n\n- `#[derive(Serialize, Deserialize)]` - makes it serializable for the template\n- `counter: Mutex>`- a modifiable map of counters" 88 | }, 89 | { 90 | "file": "src/main.rs", 91 | "selection": { 92 | "start": { 93 | "line": 26, 94 | "character": 5 95 | }, 96 | "end": { 97 | "line": 35, 98 | "character": 6 99 | } 100 | }, 101 | "description": "## Changers to the state\n\n- Lock the resource.\n- Find the counter.\n - Modify it.\n- When not found, create it." 102 | }, 103 | { 104 | "file": "src/main.rs", 105 | "selection": { 106 | "start": { 107 | "line": 49, 108 | "character": 1 109 | }, 110 | "end": { 111 | "line": 57, 112 | "character": 2 113 | } 114 | }, 115 | "description": "## The Index-Route\n\n- A new context for the template is created.\n- The counters are inserted into the context.\n- The template `index.html` get's rendered with the context.\n- The rendered html gets send to the client." 116 | }, 117 | { 118 | "file": "src/templates/index.html", 119 | "selection": { 120 | "start": { 121 | "line": 1, 122 | "character": 1 123 | }, 124 | "end": { 125 | "line": 1, 126 | "character": 26 127 | } 128 | }, 129 | "description": "The `index.html`-template extends the `base.html`-template, which contains things that are on every page." 130 | }, 131 | { 132 | "file": "src/templates/index.html", 133 | "selection": { 134 | "start": { 135 | "line": 3, 136 | "character": 1 137 | }, 138 | "end": { 139 | "line": 5, 140 | "character": 15 141 | } 142 | }, 143 | "description": "The page's title gets set." 144 | }, 145 | { 146 | "file": "src/templates/index.html", 147 | "selection": { 148 | "start": { 149 | "line": 12, 150 | "character": 5 151 | }, 152 | "end": { 153 | "line": 13, 154 | "character": 33 155 | } 156 | }, 157 | "description": "## Insert a counter in the page\n\n- Set the counter's id. Think of it as a prop (like in React) or a parameter.\n- A include the `counter.html`-template. We will have a look at it later." 158 | }, 159 | { 160 | "file": "src/main.rs", 161 | "selection": { 162 | "start": { 163 | "line": 59, 164 | "character": 1 165 | }, 166 | "end": { 167 | "line": 59, 168 | "character": 33 169 | } 170 | }, 171 | "description": "This is, where it gets interesting.\n\nIn that route, an action (`action`) gets executed for a counter (`id`)." 172 | }, 173 | { 174 | "file": "src/main.rs", 175 | "selection": { 176 | "start": { 177 | "line": 68, 178 | "character": 4 179 | }, 180 | "end": { 181 | "line": 78, 182 | "character": 6 183 | } 184 | }, 185 | "description": "When the action is `increment`, the correct action get's fired on the state. The same happends for `decrement`. If the action is not valid, a bad request is returned." 186 | }, 187 | { 188 | "file": "src/main.rs", 189 | "selection": { 190 | "start": { 191 | "line": 80, 192 | "character": 5 193 | }, 194 | "end": { 195 | "line": 83, 196 | "character": 27 197 | } 198 | }, 199 | "description": "The value of the counter and it's id get inserted into the context." 200 | }, 201 | { 202 | "file": "src/main.rs", 203 | "selection": { 204 | "start": { 205 | "line": 85, 206 | "character": 5 207 | }, 208 | "end": { 209 | "line": 87, 210 | "character": 49 211 | } 212 | }, 213 | "description": "And the `counter.html` gets rendered and responded to the user." 214 | }, 215 | { 216 | "file": "src/templates/counter.html", 217 | "selection": { 218 | "start": { 219 | "line": 1, 220 | "character": 1 221 | }, 222 | "end": { 223 | "line": 1, 224 | "character": 94 225 | } 226 | }, 227 | "description": "## The wrapper\n\nThe counter is in a div. It can be identified by its unique id.\n\n`hx-target` and `hx-swap=\"outerHTML\"` tell htmx to replace this whole div, when an action is done inside the counter." 228 | }, 229 | { 230 | "file": "src/templates/counter.html", 231 | "selection": { 232 | "start": { 233 | "line": 2, 234 | "character": 5 235 | }, 236 | "end": { 237 | "line": 5, 238 | "character": 16 239 | } 240 | }, 241 | "description": "When rendered by the `/counter/{id}/{action}`-route, count is already set, but when included in the `index.html`-template it is not. Therefore, if undefined, it gets set using it's `id`." 242 | }, 243 | { 244 | "file": "src/templates/counter.html", 245 | "selection": { 246 | "start": { 247 | "line": 6, 248 | "character": 5 249 | }, 250 | "end": { 251 | "line": 6, 252 | "character": 86 253 | } 254 | }, 255 | "description": "When clicked at the decrement-button, a GET is send to `/counter/{id}/decrement`. Then, the whole counter gets swapped with the response." 256 | }, 257 | { 258 | "file": "README.md", 259 | "selection": { 260 | "start": { 261 | "line": 3, 262 | "character": 1 263 | }, 264 | "end": { 265 | "line": 3, 266 | "character": 88 267 | } 268 | }, 269 | "description": "Have fun!" 270 | } 271 | ], 272 | "isPrimary": true 273 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "htmx-rust-tera-template" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | actix-web = "4.4.1" 10 | actix-files = "0.6.5" 11 | tera = "1.19.1" 12 | serde = { version = "1.0.195", features = ["derive"] } 13 | color-eyre = "0.6.2" 14 | lazy_static = "1.4.0" 15 | 16 | [lints.rust] 17 | unsafe_code = "forbid" 18 | 19 | [lints.clippy] 20 | enum_glob_use = "deny" 21 | pedantic = "deny" 22 | nursery = "deny" 23 | unwrap_used = "allow" # to make it less annoying to get started 24 | 25 | [profile.release] 26 | #opt-level = 'z' # Optimize for size. 27 | lto = true # Enable Link Time Optimisation 28 | codegen-units = 1 # Reduced to increase optimisations. 29 | panic = 'abort' # Abort on panic 30 | #strip = "symbols" # Strip symbols from binary 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htxm-rust-tera-template 2 | 3 | A template for a htmx and rust powered web application using the tera template engine. 4 | 5 | ## Getting Started 6 | 7 | 1. Click the "Use this template" button on the github page to create a new repository based on this template. 8 | 2. Clone your new repository. 9 | 10 | ### Option A: Do the tour 11 | 12 | 3. Install the [codetour extension](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.codetour) in vscode. 13 | 4. Open the repository in vscode and start the tour by clicking the butto in codetour's notification or by pressing `ctrl+shift+p` and typing `CodeTour: Start Tour`. 14 | 15 | ### Option B: Just run it 16 | 17 | 3. Run `cargo run` to start the server (or `bacon run` if you have [bacon](https://crates.io/crates/bacon) installed). 18 | 4. Open http://localhost:8080 in your browser. 19 | 20 | ## Libraries in this template 21 | 22 | - [htmx](https://htmx.org/) - Of course! We don't want to write any javascript. 23 | - [rust](https://www.rust-lang.org/) - Because rust is superiour. 24 | - [actix-web](https://actix.rs/) - A fast web framework for rust. 25 | - [actix-files](https://crates.io/crates/actix-files) - For serving static files. 26 | - [tera](https://keats.github.io/tera/) - A for writing reusable html templates. 27 | - [serde](https://crates.io/crates/serde) - For serializing and deserializing data. 28 | - [color-eyre](https://crates.io/crates/color-eyre) - For better error messages. 29 | - [lazy_static](https://crates.io/crates/lazy_static) - For better static variables. 30 | 31 | ## Contributing 32 | 33 | 1. Please open an issue first to discuss what you would like to change. 34 | 2. Fork the repository and create a new branch. 35 | 3. Make your changes and commit them. 36 | 4. Open a pull request. 37 | 5. Wait for me to review your changes. 38 | 6. Celebrate! 🎉 39 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{get, web, HttpServer, Responder}; 2 | use color_eyre::eyre::Result; 3 | use lazy_static::lazy_static; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{collections::HashMap, sync::Mutex}; 6 | use tera::Tera; 7 | 8 | lazy_static! { 9 | pub static ref TEMPLATES: Tera = { 10 | match Tera::new("src/templates/**/*") { 11 | Ok(t) => t, 12 | Err(e) => { 13 | println!("Template parsing error(s): {e}"); 14 | ::std::process::exit(1); 15 | } 16 | } 17 | }; 18 | } 19 | 20 | #[derive(Serialize, Deserialize)] 21 | struct AppState { 22 | counter: Mutex>, 23 | } 24 | 25 | impl AppState { 26 | fn increment(&self, id: &str) { 27 | let mut counter = self.counter.lock().unwrap(); 28 | 29 | if counter.contains_key(id) { 30 | let count = counter.get_mut(id).unwrap(); 31 | *count += 1; 32 | } else { 33 | counter.insert(id.to_string(), 0); 34 | } 35 | } 36 | 37 | fn decrement(&self, id: &str) { 38 | let mut counter = self.counter.lock().unwrap(); 39 | 40 | if counter.contains_key(id) { 41 | let count = counter.get_mut(id).unwrap(); 42 | *count -= 1; 43 | } else { 44 | counter.insert(id.to_string(), 0); 45 | } 46 | } 47 | } 48 | 49 | #[get("/")] 50 | async fn index(state: actix_web::web::Data) -> impl Responder { 51 | let mut ctx = tera::Context::new(); 52 | ctx.insert("counter", &*state.counter.lock().unwrap()); 53 | 54 | let rendered = TEMPLATES.render("index.html", &ctx).unwrap(); 55 | 56 | actix_web::HttpResponse::Ok().body(rendered) 57 | } 58 | 59 | #[get("/counter/{id}/{action}")] 60 | async fn counter_handler( 61 | action: actix_web::web::Path<(String, String)>, 62 | state: actix_web::web::Data, 63 | ) -> impl Responder { 64 | let (id, action) = action.into_inner(); 65 | 66 | let mut ctx = tera::Context::new(); 67 | 68 | match action.as_str() { 69 | "increment" => { 70 | state.increment(&id); 71 | } 72 | "decrement" => { 73 | state.decrement(&id); 74 | } 75 | _ => { 76 | return actix_web::HttpResponse::BadRequest().body("Invalid action"); 77 | } 78 | } 79 | 80 | let counter = state.counter.lock().unwrap(); 81 | let count = counter.get(&id).unwrap_or(&0); 82 | ctx.insert("count", count); 83 | ctx.insert("id", &id); 84 | 85 | let rendered = TEMPLATES.render("counter.html", &ctx).unwrap(); 86 | 87 | actix_web::HttpResponse::Ok().body(rendered) 88 | } 89 | 90 | lazy_static! { 91 | static ref DEFAULT_COUNTERS: Vec<(String, i32)> = 92 | vec![("c1".to_string(), 0), ("c2".to_string(), 10)]; 93 | } 94 | 95 | #[actix_web::main] 96 | async fn main() -> Result<()> { 97 | color_eyre::install()?; 98 | 99 | HttpServer::new(|| { 100 | actix_web::App::new() 101 | // register state 102 | .app_data(web::Data::new(AppState { 103 | counter: Mutex::new(DEFAULT_COUNTERS.clone().into_iter().collect()), 104 | })) 105 | // index route 106 | .service(index) 107 | // counter route 108 | .service(counter_handler) 109 | // static files 110 | .service(actix_files::Files::new("/", "./src/static/").show_files_listing()) 111 | }) 112 | .bind("0.0.0.0:8080")? 113 | .run() 114 | .await?; 115 | 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /src/static/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | --background-color: #f5f5f5; 9 | --primary-color: #1a73e8; 10 | --text-color: #202124; 11 | } 12 | 13 | @media screen and (prefers-color-scheme: dark) { 14 | :root { 15 | --background-color: #202124; 16 | --primary-color: #1a73e8; 17 | --text-color: #f5f5f5; 18 | } 19 | } 20 | 21 | body { 22 | font-family: 'Roboto', sans-serif; 23 | background-color: var(--background-color); 24 | color: var(--text-color); 25 | } 26 | 27 | main { 28 | width: 100%; 29 | max-width: 30 | 75rem; 31 | margin: 0 auto; 32 | padding: 1rem; 33 | } 34 | 35 | .counters { 36 | display: grid; 37 | grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); 38 | grid-gap: 1rem; 39 | } 40 | 41 | .counter { 42 | border: 1px solid var(--primary-color); 43 | border-radius: 0.25rem; 44 | padding: 1rem; 45 | margin-bottom: 1rem; 46 | width: 100%; 47 | 48 | display: flex; 49 | flex-direction: row; 50 | justify-content: space-between; 51 | } 52 | 53 | .counter button { 54 | border: none; 55 | background-color: var(--primary-color); 56 | color: var(--background-color); 57 | padding: 0.5rem 1rem; 58 | border-radius: 0.25rem; 59 | font-size: 1rem; 60 | cursor: pointer; 61 | } 62 | 63 | .counter-count { 64 | line-height: 1; 65 | font-weight: bold; 66 | } -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block title %} 9 | No Title 10 | {% endblock %} 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 |
21 | {% block content %} 22 | 23 | {% endblock %} 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /src/templates/counter.html: -------------------------------------------------------------------------------- 1 |
2 | {% if count is defined %} 3 | {% else %} 4 | {% set count = counter[id] %} 5 | {% endif %} 6 | 7 |

8 | Count: 9 | {{ count }} 10 |

11 | 12 | 13 |
-------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} 4 | Index 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

Index

9 |

Counter

10 | 11 |
12 | {% set id='c1' %} 13 | {% include 'counter.html' %} 14 | 15 | {% set id='c2' %} 16 | {% include 'counter.html' %} 17 |
18 | 19 | {% endblock %} --------------------------------------------------------------------------------