diff --git a/Cargo.toml b/Cargo.toml
index d3b839e35..54e2b2717 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,4 +1,8 @@
-workspace = { members = ["macros", "xtask"], exclude = ["starters"] }
+[workspace]
+members = ["macros", "xtask"]
+exclude = ["starters"]
+
+
[package]
name = "loco-rs"
version = "0.2.2"
diff --git a/docs-site/content/docs/getting-started/starters.md b/docs-site/content/docs/getting-started/starters.md
index 3e1c3fcbc..d41db9a75 100644
--- a/docs-site/content/docs/getting-started/starters.md
+++ b/docs-site/content/docs/getting-started/starters.md
@@ -39,6 +39,7 @@ The Saas starter is perfect for projects requiring both a frontend website and a
- Built on React and Vite (easy to replace with your preferred framework).
- Static middleware that point on your frontend build and includes a fallback index.
+- The Tera view engine configured for server side view templates, including i18n configuration. Templates and i18n assets live in `assets/`
**Rest API**
diff --git a/docs-site/content/docs/the-app/views.md b/docs-site/content/docs/the-app/views.md
index 0dd13dca4..bea56aa15 100644
--- a/docs-site/content/docs/the-app/views.md
+++ b/docs-site/content/docs/the-app/views.md
@@ -14,7 +14,20 @@ toc = true
top = false
+++
-In `Loco`, the processing of web requests is divided between action controller, action view and action model. action model primarily deals with communicating with the database and executing CRUD operations when required. Action controller is handling requests parsing payload, On the other hand Action View takes on the responsibility of assembling and rendering the final response to be sent back to the client. This separation of concerns allows for a clear and organized handling of the request-response lifecycle in a Rails application.
+In `Loco`, the processing of web requests is divided between a controller, model and view.
+
+* **The controller** is handling requests parsing payload, and then control flows to models
+* **The model** primarily deals with communicating with the database and executing CRUD operations when required. As well as modeling all business and domain logic and operations.
+* **The view** takes on the responsibility of assembling and rendering the final response to be sent back to the client.
+
+
+You can choose to have _JSON views_, which are JSON responses, or _Template views_ which are powered by a template view engine and eventually are HTML responses. You can also combine both.
+
+
+This is similar in spirit to Rails' `jbuilder` views which are JSON, and regular views, which are HTML, only that in LOCO we focus on being JSON-first.
+
+
+## JSON views
For an examples, we have an endpoint that handling user login request. in this case we creating an [controller](@/docs/the-app/controller.md) the defined the user payload and parsing in into the model for check if the user request is valid.
When the user is valid we can pass the `user` model into the `auth` view which take the user and parsing the relavant detatils that we want to return in the request.
@@ -64,3 +77,127 @@ impl LoginResponse {
}
```
+
+## Template views
+
+Loco has a _view engine infrastructure_ built in. It means that you can take any kind of a template engine like Tera and Liquid, implement a `TemplateEngine` trait and use those in your controllers.
+
+We provide a built in `TeraView` engine which requires no coding, it's ready use. To use this engine you need to verify that you have a `ViewEngineInitializer` in `initializers/view_engine.rs` which is also specified in your `app.rs`. If you used the SaaS Starter, this should already be configured for you.
+
+
+NOTE: The SaaS starter includes a fully configured Tera view engine, which includes an i18n library and asset loading, which is also wired into your app hooks
+
+
+### Customizing the view engine
+
+Out of the box, the Tera view engine comes with the following configured:
+
+* Template loading and location: `assets/**/*.html`
+* Internationalization (i18n) configured into the Tera view engine, you get the translation function: `t(..)` to use in your templates
+
+If you want to change any configuration detail for the `i18n` library, you can go and edit `src/initializers/view_engine.rs`.
+
+You can also add custom Tera functions in the same initializer.
+
+### Creating a new view
+
+First, create a template. In this case we add a Tera template, in `assets/views/home/hello.html`. Note that **assets/** sits in the root of your project (next to `src/` and `config/`).
+
+```html
+
+find this tera template at assets/views/home/hello.html
:
+
+
+{{ t(key="hello-world", lang="en-US") }},
+
+{{ t(key="hello-world", lang="de-DE") }}
+
+
+```
+
+Now create a strongly typed `view` to encapsulate this template in `src/views/dashboard.rs`:
+
+```rust
+// src/views/dashboard.rs
+pub fn home(v: impl ViewRenderer) -> Result {
+ format::render().view(&v, "home/hello.html", json!({}))
+}
+```
+
+And add it to `src/views/mod.rs`:
+
+```rust
+pub mod dashboard;
+```
+
+Finally, go to your controller and use the view:
+
+
+```rust
+// src/controllers/dashboard.rs
+pub async fn render_home(ViewEngine(v): ViewEngine) -> Result {
+ views::dashboard::home(v)
+}
+```
+
+### How does it work?
+
+* `ViewEngine` is an extractor that's available to you via `loco_rs::prelude::*`
+* `TeraView` is the Tera view engine that we supply with Loco also available via `loco_rs::prelude::*`
+* Controllers need to deal with getting a request, calling some model logic, and then supplying a view with **models and other data**, not caring about how the view does its thing
+* `views::dashboard::home` is an opaque call, it hides the details of how a view works, or how the bytes find their way into a browser, which is a _Good Thing_
+* Should you ever want to swap a view engine, the encapsulation here works like magic. You can change the extractor type: `ViewEngine` and everything works, because `v` is eventually just a `ViewRenderer` trait
+
+## Using your own view engine
+
+If you do not like Tera as a view engine, or want to use Handlebars, or others you can create your own custom view engine very easily.
+
+Here's an example for a dummy "Hello" view engine. It's a view engine that always returns the word _hello_.
+
+```rust
+// src/initializers/hello_view_engine.rs
+use axum::{async_trait, Extension, Router as AxumRouter};
+use loco_rs::{
+ app::{AppContext, Initializer},
+ controller::views::{ViewEngine, ViewRenderer},
+ Result,
+};
+use serde::Serialize;
+
+#[derive(Clone)]
+pub struct HelloView;
+impl ViewRenderer for HelloView {
+ fn render(&self, _key: &str, _data: S) -> Result {
+ Ok("hello".to_string())
+ }
+}
+
+pub struct HelloViewEngineInitializer;
+#[async_trait]
+impl Initializer for HelloViewEngineInitializer {
+ fn name(&self) -> String {
+ "custom-view-engine".to_string()
+ }
+
+ async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result {
+ Ok(router.layer(Extension(ViewEngine::from(HelloView))))
+ }
+}
+```
+
+To use it, you need to add it to your `src/app.rs` hooks:
+
+
+```rust
+// src/app.rs
+// add your custom "hello" view engine in the `initializers(..)` hook
+impl Hooks for App {
+ // ...
+ async fn initializers(_ctx: &AppContext) -> Result>> {
+ Ok(vec![
+ // ,.----- add it here
+ Box::new(initializers::hello_view_engine::HelloViewEngineInitializer),
+ ])
+ }
+ // ...
+```
diff --git a/examples/demo/Cargo.lock b/examples/demo/Cargo.lock
index 8749233da..f12d6a800 100644
--- a/examples/demo/Cargo.lock
+++ b/examples/demo/Cargo.lock
@@ -181,6 +181,12 @@ version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
+[[package]]
+name = "arc-swap"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
+
[[package]]
name = "argon2"
version = "0.5.2"
@@ -703,6 +709,7 @@ dependencies = [
"axum_session",
"chrono",
"eyre",
+ "fluent-templates",
"include_dir",
"insta",
"loco-macros",
@@ -713,10 +720,12 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
+ "tera",
"tokio",
"tracing",
"tracing-subscriber",
"trycmd",
+ "unic-langid",
"uuid",
"validator",
]
@@ -1328,6 +1337,23 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "displaydoc"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.39",
+]
+
+[[package]]
+name = "doc-comment"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -1490,6 +1516,99 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fluent"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61f69378194459db76abd2ce3952b790db103ceb003008d3d50d97c41ff847a7"
+dependencies = [
+ "fluent-bundle",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-bundle"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e242c601dec9711505f6d5bbff5bedd4b61b2469f2e8bb8e57ee7c9747a87ffd"
+dependencies = [
+ "fluent-langneg",
+ "fluent-syntax",
+ "intl-memoizer",
+ "intl_pluralrules",
+ "rustc-hash",
+ "self_cell 0.10.3",
+ "smallvec",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-langneg"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94"
+dependencies = [
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-syntax"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0abed97648395c902868fee9026de96483933faa54ea3b40d652f7dfe61ca78"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "fluent-template-macros"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec7592cd1f45c1afe9084ce59c62a3a7c266c125c4c2ec97e95b0563c4aa914"
+dependencies = [
+ "flume 0.10.14",
+ "ignore",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-templates"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c3ef2c2152757885365abce32ddf682746062f1b6b3c0824a29fbed6ee4d080"
+dependencies = [
+ "arc-swap",
+ "fluent",
+ "fluent-bundle",
+ "fluent-langneg",
+ "fluent-syntax",
+ "fluent-template-macros",
+ "flume 0.10.14",
+ "heck",
+ "ignore",
+ "intl-memoizer",
+ "lazy_static",
+ "log",
+ "once_cell",
+ "serde_json",
+ "snafu",
+ "tera",
+ "unic-langid",
+]
+
+[[package]]
+name = "flume"
+version = "0.10.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577"
+dependencies = [
+ "spin 0.9.8",
+]
+
[[package]]
name = "flume"
version = "0.11.0"
@@ -2134,6 +2253,25 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "intl-memoizer"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c310433e4a310918d6ed9243542a6b83ec1183df95dff8f23f87bb88a264a66f"
+dependencies = [
+ "type-map",
+ "unic-langid",
+]
+
+[[package]]
+name = "intl_pluralrules"
+version = "7.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
+dependencies = [
+ "unic-langid",
+]
+
[[package]]
name = "io-lifetimes"
version = "1.0.11"
@@ -2994,6 +3132,12 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "proc-macro-hack"
+version = "0.5.20+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
+
[[package]]
name = "proc-macro2"
version = "1.0.70"
@@ -3379,6 +3523,12 @@ version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
[[package]]
name = "rustc_version"
version = "0.4.0"
@@ -3674,6 +3824,21 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+[[package]]
+name = "self_cell"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d"
+dependencies = [
+ "self_cell 1.0.3",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba"
+
[[package]]
name = "semver"
version = "1.0.20"
@@ -3981,6 +4146,28 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+[[package]]
+name = "snafu"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6"
+dependencies = [
+ "doc-comment",
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "snapbox"
version = "0.4.14"
@@ -4269,7 +4456,7 @@ checksum = "210976b7d948c7ba9fced8ca835b11cbb2d677c59c79de41ac0d397e14547490"
dependencies = [
"atoi",
"chrono",
- "flume",
+ "flume 0.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -4499,6 +4686,15 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tinystr"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83c02bf3c538ab32ba913408224323915f4ef9a6d61c0e85d493f355921c0ece"
+dependencies = [
+ "displaydoc",
+]
+
[[package]]
name = "tinyvec"
version = "1.6.0"
@@ -4752,6 +4948,15 @@ dependencies = [
"toml_edit",
]
+[[package]]
+name = "type-map"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d3364c5e96cb2ad1603037ab253ddd34d7fb72a58bdddf4b7350760fc69a46"
+dependencies = [
+ "rustc-hash",
+]
+
[[package]]
name = "typenum"
version = "1.17.0"
@@ -4785,6 +4990,49 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+[[package]]
+name = "unic-langid"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "238722e6d794ed130f91f4ea33e01fcff4f188d92337a21297892521c72df516"
+dependencies = [
+ "unic-langid-impl",
+ "unic-langid-macros",
+]
+
+[[package]]
+name = "unic-langid-impl"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd55a2063fdea4ef1f8633243a7b0524cbeef1905ae04c31a1c9b9775c55bc6"
+dependencies = [
+ "tinystr",
+]
+
+[[package]]
+name = "unic-langid-macros"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c854cefb82ff2816410ce606acbad1b3af065140907b29be9229040752b83ec"
+dependencies = [
+ "proc-macro-hack",
+ "tinystr",
+ "unic-langid-impl",
+ "unic-langid-macros-impl",
+]
+
+[[package]]
+name = "unic-langid-macros-impl"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea2a4c80deb4fb3ca51f66b5e2dd91e3642bbce52234bcf22e41668281208e4"
+dependencies = [
+ "proc-macro-hack",
+ "quote",
+ "syn 2.0.39",
+ "unic-langid-impl",
+]
+
[[package]]
name = "unic-segment"
version = "0.9.0"
diff --git a/examples/demo/Cargo.toml b/examples/demo/Cargo.toml
index fa71ac01e..de77798e0 100644
--- a/examples/demo/Cargo.toml
+++ b/examples/demo/Cargo.toml
@@ -35,6 +35,9 @@ include_dir = "0.7"
uuid = { version = "1.6.0", features = ["v4"] }
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
+fluent-templates = { version = "0.8.0", features = ["tera"] }
+unic-langid = "0.9.4"
+tera = "1.19.1"
[[bin]]
name = "blo-cli"
diff --git a/examples/demo/assets/i18n/de-DE/main.ftl b/examples/demo/assets/i18n/de-DE/main.ftl
new file mode 100644
index 000000000..ced609fe4
--- /dev/null
+++ b/examples/demo/assets/i18n/de-DE/main.ftl
@@ -0,0 +1,4 @@
+hello-world = Hallo Welt!
+greeting = Hallochen { $name }!
+ .placeholder = Hallo Freund!
+about = Uber
diff --git a/examples/demo/assets/i18n/en-US/main.ftl b/examples/demo/assets/i18n/en-US/main.ftl
new file mode 100644
index 000000000..9d4d5e7c4
--- /dev/null
+++ b/examples/demo/assets/i18n/en-US/main.ftl
@@ -0,0 +1,10 @@
+hello-world = Hello World!
+greeting = Hello { $name }!
+ .placeholder = Hello Friend!
+about = About
+simple = simple text
+reference = simple text with a reference: { -something }
+parameter = text with a { $param }
+parameter2 = text one { $param } second { $multi-word-param }
+email = text with an EMAIL("example@example.org")
+fallback = this should fall back
diff --git a/examples/demo/assets/i18n/shared.ftl b/examples/demo/assets/i18n/shared.ftl
new file mode 100644
index 000000000..f169eca9d
--- /dev/null
+++ b/examples/demo/assets/i18n/shared.ftl
@@ -0,0 +1 @@
+-something = foo
diff --git a/examples/demo/assets/static/404.html b/examples/demo/assets/static/404.html
new file mode 100644
index 000000000..66e78fb22
--- /dev/null
+++ b/examples/demo/assets/static/404.html
@@ -0,0 +1,3 @@
+
+not found :-(
+
diff --git a/examples/demo/assets/static/image.png b/examples/demo/assets/static/image.png
new file mode 100644
index 000000000..fa5a09508
Binary files /dev/null and b/examples/demo/assets/static/image.png differ
diff --git a/examples/demo/assets/views/home/hello.html b/examples/demo/assets/views/home/hello.html
new file mode 100644
index 000000000..6b97c398e
--- /dev/null
+++ b/examples/demo/assets/views/home/hello.html
@@ -0,0 +1,12 @@
+
+
+
+ find this tera template at assets/views/home/hello.html
:
+
+
+ {{ t(key="hello-world", lang="en-US") }},
+
+ {{ t(key="hello-world", lang="de-DE") }}
+
+
+
\ No newline at end of file
diff --git a/examples/demo/src/app.rs b/examples/demo/src/app.rs
index d632e9f26..2cba1684b 100644
--- a/examples/demo/src/app.rs
+++ b/examples/demo/src/app.rs
@@ -1,7 +1,6 @@
use std::path::Path;
use async_trait::async_trait;
-use axum::Router as AxumRouter;
use loco_rs::{
app::{AppContext, Hooks, Initializer},
boot::{create_app, BootResult, StartMode},
@@ -16,8 +15,7 @@ use migration::Migrator;
use sea_orm::DatabaseConnection;
use crate::{
- controllers::{self, auth::routes},
- initializers::{self, axum_session::AxumSessionInitializer},
+ controllers, initializers,
models::_entities::{notes, users},
tasks,
workers::downloader::DownloadWorker,
@@ -41,9 +39,11 @@ impl Hooks for App {
}
async fn initializers(_ctx: &AppContext) -> Result>> {
- Ok(vec![Box::new(
- initializers::axum_session::AxumSessionInitializer,
- )])
+ Ok(vec![
+ Box::new(initializers::axum_session::AxumSessionInitializer),
+ Box::new(initializers::view_engine::ViewEngineInitializer),
+ Box::new(initializers::hello_view_engine::HelloViewEngineInitializer),
+ ])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
@@ -51,6 +51,7 @@ impl Hooks for App {
.add_route(controllers::notes::routes())
.add_route(controllers::auth::routes())
.add_route(controllers::mysession::routes())
+ .add_route(controllers::dashboard::routes())
.add_route(controllers::user::routes())
}
diff --git a/examples/demo/src/controllers/dashboard.rs b/examples/demo/src/controllers/dashboard.rs
new file mode 100644
index 000000000..0c41cb0dd
--- /dev/null
+++ b/examples/demo/src/controllers/dashboard.rs
@@ -0,0 +1,27 @@
+#![allow(clippy::unused_async)]
+use loco_rs::prelude::*;
+
+use crate::{initializers::hello_view_engine::HelloView, views};
+
+/// Renders the dashboard home page
+///
+/// # Errors
+///
+/// This function will return an error if render fails
+pub async fn render_home(ViewEngine(v): ViewEngine) -> Result {
+ views::dashboard::home(v)
+}
+
+pub async fn render_hello(ViewEngine(v): ViewEngine) -> Result {
+ // NOTE: v is a hello engine, which always returns 'hello', params dont matter.
+ // it's a funky behavior that we use for demonstrating how easy it is
+ // to build a custom view engine.
+ format::render().view(&v, "foobar", ())
+}
+
+pub fn routes() -> Routes {
+ Routes::new()
+ .prefix("dashboard")
+ .add("/home", get(render_home))
+ .add("/hello", get(render_hello))
+}
diff --git a/examples/demo/src/controllers/mod.rs b/examples/demo/src/controllers/mod.rs
index ec51d048b..42e7901bd 100644
--- a/examples/demo/src/controllers/mod.rs
+++ b/examples/demo/src/controllers/mod.rs
@@ -1,4 +1,5 @@
pub mod auth;
+pub mod dashboard;
pub mod mysession;
pub mod notes;
pub mod user;
diff --git a/examples/demo/src/initializers/axum_session.rs b/examples/demo/src/initializers/axum_session.rs
index 04c386749..fb01bbee5 100644
--- a/examples/demo/src/initializers/axum_session.rs
+++ b/examples/demo/src/initializers/axum_session.rs
@@ -1,8 +1,7 @@
use async_trait::async_trait;
use axum::Router as AxumRouter;
-use loco_rs::{controller::AppRoutes, prelude::*};
+use loco_rs::prelude::*;
-use crate::controllers::mysession;
pub struct AxumSessionInitializer;
#[async_trait]
diff --git a/examples/demo/src/initializers/hello_view_engine.rs b/examples/demo/src/initializers/hello_view_engine.rs
new file mode 100644
index 000000000..e160b7520
--- /dev/null
+++ b/examples/demo/src/initializers/hello_view_engine.rs
@@ -0,0 +1,27 @@
+use axum::{async_trait, Extension, Router as AxumRouter};
+use loco_rs::{
+ app::{AppContext, Initializer},
+ controller::views::{ViewEngine, ViewRenderer},
+ Result,
+};
+use serde::Serialize;
+
+#[derive(Clone)]
+pub struct HelloView;
+impl ViewRenderer for HelloView {
+ fn render(&self, _key: &str, _data: S) -> Result {
+ Ok("hello".to_string())
+ }
+}
+
+pub struct HelloViewEngineInitializer;
+#[async_trait]
+impl Initializer for HelloViewEngineInitializer {
+ fn name(&self) -> String {
+ "custom-view-engine".to_string()
+ }
+
+ async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result {
+ Ok(router.layer(Extension(ViewEngine::from(HelloView))))
+ }
+}
diff --git a/examples/demo/src/initializers/mod.rs b/examples/demo/src/initializers/mod.rs
index 0d4596d1f..c289cb97d 100644
--- a/examples/demo/src/initializers/mod.rs
+++ b/examples/demo/src/initializers/mod.rs
@@ -1 +1,4 @@
+#![allow(clippy::module_name_repetitions)]
pub mod axum_session;
+pub mod hello_view_engine;
+pub mod view_engine;
diff --git a/examples/demo/src/initializers/view_engine.rs b/examples/demo/src/initializers/view_engine.rs
new file mode 100644
index 000000000..f0a5ffba9
--- /dev/null
+++ b/examples/demo/src/initializers/view_engine.rs
@@ -0,0 +1,36 @@
+use axum::{async_trait, Extension, Router as AxumRouter};
+use fluent_templates::{ArcLoader, FluentLoader};
+use loco_rs::{
+ app::{AppContext, Initializer},
+ controller::views::{engines, ViewEngine},
+ Error, Result,
+};
+use tracing::info;
+
+const I18N_DIR: &str = "assets/i18n";
+const I18N_SHARED: &str = "assets/i18n/shared.ftl";
+
+pub struct ViewEngineInitializer;
+#[async_trait]
+impl Initializer for ViewEngineInitializer {
+ fn name(&self) -> String {
+ "view-engine".to_string()
+ }
+
+ async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result {
+ let mut tera_engine = engines::TeraView::build()?;
+ if std::path::Path::new(I18N_DIR).exists() {
+ let arc = ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
+ .shared_resources(Some(&[I18N_SHARED.into()]))
+ .customize(|bundle| bundle.set_use_isolating(false))
+ .build()
+ .map_err(|e| Error::string(&e.to_string()))?;
+ tera_engine
+ .tera
+ .register_function("t", FluentLoader::new(arc));
+ info!("locales loaded");
+ }
+
+ Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
+ }
+}
diff --git a/examples/demo/src/views/dashboard.rs b/examples/demo/src/views/dashboard.rs
new file mode 100644
index 000000000..2d41ad239
--- /dev/null
+++ b/examples/demo/src/views/dashboard.rs
@@ -0,0 +1,6 @@
+use loco_rs::prelude::*;
+use serde_json::json;
+
+pub fn home(v: impl ViewRenderer) -> Result {
+ format::render().view(&v, "home/hello.html", json!({}))
+}
diff --git a/examples/demo/src/views/mod.rs b/examples/demo/src/views/mod.rs
index 0a3d0b5fb..1597ab4fe 100644
--- a/examples/demo/src/views/mod.rs
+++ b/examples/demo/src/views/mod.rs
@@ -1,3 +1,4 @@
pub mod auth;
+pub mod dashboard;
pub mod notes;
pub mod user;
diff --git a/examples/demo/tests/cmd/cli.trycmd b/examples/demo/tests/cmd/cli.trycmd
index 2eff364b5..7b1d25e40 100644
--- a/examples/demo/tests/cmd/cli.trycmd
+++ b/examples/demo/tests/cmd/cli.trycmd
@@ -87,6 +87,8 @@ $ blo-cli routes --environment test
[POST] /auth/register
[POST] /auth/reset
[POST] /auth/verify
+[GET] /dashboard/hello
+[GET] /dashboard/home
[GET] /mysession
[GET] /notes
[POST] /notes
diff --git a/src/controller/format.rs b/src/controller/format.rs
index 273da821a..b76e48166 100644
--- a/src/controller/format.rs
+++ b/src/controller/format.rs
@@ -29,6 +29,7 @@ use bytes::{BufMut, BytesMut};
use hyper::{header, StatusCode};
use serde::Serialize;
+use super::views::ViewRenderer;
use crate::{controller::Json, Result};
/// Returns an empty response.
@@ -127,6 +128,20 @@ pub fn html(content: &str) -> Result> {
Ok(Html(content.to_string()))
}
+/// Render template located by `key`
+///
+/// # Errors
+///
+/// This function will return an error if rendering fails
+pub fn view(v: &V, key: &str, data: S) -> Result>
+where
+ V: ViewRenderer,
+ S: Serialize,
+{
+ let res = v.render(key, data)?;
+ html(&res)
+}
+
pub struct RenderBuilder {
response: Builder,
}
@@ -222,6 +237,20 @@ impl RenderBuilder {
Ok(self.response.body(Body::empty())?)
}
+ /// Render template located by `key`
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if rendering fails
+ pub fn view(self, v: &V, key: &str, data: S) -> Result
+ where
+ V: ViewRenderer,
+ S: Serialize,
+ {
+ let content = v.render(key, data)?;
+ self.html(&content)
+ }
+
/// Finalize and return a HTML response
///
/// # Errors
diff --git a/src/controller/views/engines.rs b/src/controller/views/engines.rs
new file mode 100644
index 000000000..fddc63cce
--- /dev/null
+++ b/src/controller/views/engines.rs
@@ -0,0 +1,65 @@
+use std::path::Path;
+
+use serde::Serialize;
+
+use crate::{controller::views::ViewRenderer, Error, Result};
+
+const VIEWS_DIR: &str = "assets/views/";
+const VIEWS_GLOB: &str = "assets/views/**/*.html";
+
+#[derive(Clone, Debug)]
+pub struct TeraView {
+ pub tera: tera::Tera,
+ pub default_context: tera::Context,
+}
+
+impl TeraView {
+ /// Create a Tera view engine
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if building fails
+ pub fn build() -> Result {
+ if !Path::new(VIEWS_DIR).exists() {
+ return Err(Error::string(&format!(
+ "missing views directory: `{VIEWS_DIR}`"
+ )));
+ }
+
+ let tera = tera::Tera::new(VIEWS_GLOB)?;
+ let ctx = tera::Context::default();
+ Ok(Self {
+ tera,
+ default_context: ctx,
+ })
+ }
+}
+
+impl ViewRenderer for TeraView {
+ fn render(&self, key: &str, data: S) -> Result {
+ let context = tera::Context::from_serialize(data)?;
+
+ // NOTE: this supports full reload of template for every render request.
+ // it means that you will see refreshed content without rebuild and rerun
+ // of the app.
+ // the code here is required, since Tera has no "build every time your render"
+ // mode, which would have been better.
+ // we minimize risk by flagging this in debug (development) builds only
+ // for now we leave this commented out, we propose people use `cargo-watch`
+ // we want to delay using un__safe as much as possible.
+ /*
+ #[cfg(debug_assertions)]
+ {
+ let ptr = std::ptr::addr_of!(self.tera);
+ let mut_ptr = ptr.cast_mut();
+ // fix this keyword
+ un__safe {
+ let tera = &mut *mut_ptr;
+ tera.full_reload()?;
+ }
+ }
+ */
+
+ Ok(self.tera.render(key, &context)?)
+ }
+}
diff --git a/src/controller/views/mod.rs b/src/controller/views/mod.rs
index f74c60cbb..93e866c50 100644
--- a/src/controller/views/mod.rs
+++ b/src/controller/views/mod.rs
@@ -1,2 +1,63 @@
+pub mod engines;
+use axum::{async_trait, extract::FromRequestParts, http::request::Parts, Extension};
+use serde::Serialize;
+
+use crate::Result;
+
#[cfg(feature = "with-db")]
pub mod pagination;
+
+pub trait ViewRenderer {
+ /// Render a view template located by `key`
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if render fails
+ fn render(&self, key: &str, data: S) -> Result;
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct ViewEngine(pub E);
+
+impl ViewEngine {
+ /// Creates a new [`Engine`] that wraps the given engine
+ pub fn new(engine: E) -> Self {
+ Self(engine)
+ }
+}
+
+impl From for ViewEngine {
+ fn from(inner: E) -> Self {
+ Self::new(inner)
+ }
+}
+
+#[async_trait]
+impl FromRequestParts for ViewEngine
+where
+ S: Send + Sync,
+ E: Clone + Send + Sync + 'static,
+{
+ type Rejection = std::convert::Infallible;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &S,
+ ) -> std::result::Result {
+ let Extension(tl): Extension = Extension::from_request_parts(parts, state)
+ .await
+ .expect("TeraLayer missing. Is the TeraLayer installed?");
+ /*
+ let locale = parts
+ .headers
+ .get("Accept-Language")
+ .unwrap()
+ .to_str()
+ .unwrap();
+ // BUG: this does not mutate or set anything because of clone
+ tl.default_context.clone().insert("locale", &locale);
+ */
+
+ Ok(tl)
+ }
+}
diff --git a/src/prelude.rs b/src/prelude.rs
index f9d2bf017..70fff0be6 100644
--- a/src/prelude.rs
+++ b/src/prelude.rs
@@ -1,6 +1,7 @@
pub use async_trait::async_trait;
pub use axum::{
extract::{Form, Path, State},
+ response::IntoResponse,
routing::{delete, get, post, put},
};
pub use axum_extra::extract::cookie;
@@ -13,7 +14,11 @@ pub use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, ModelTrait, Se
pub use crate::controller::middleware::auth;
pub use crate::{
app::{AppContext, Initializer},
- controller::{format, not_found, unauthorized, Json, Routes},
+ controller::{
+ format, not_found, unauthorized,
+ views::{engines::TeraView, ViewEngine, ViewRenderer},
+ Json, Routes,
+ },
errors::Error,
mailer,
mailer::Mailer,
diff --git a/starters/saas/config/development.yaml b/starters/saas/config/development.yaml
index 72531f0f0..afb8ed983 100644
--- a/starters/saas/config/development.yaml
+++ b/starters/saas/config/development.yaml
@@ -61,6 +61,7 @@ server:
static:
enable: true
must_exist: true
+ precompressed: false
folder:
uri: "/"
path: "frontend/dist"