├── .github
├── dependabot.yml
└── workflows
│ ├── audit-on-push.yml
│ ├── general.yml
│ └── scheduled-audit.yml
├── .gitignore
├── Cargo.toml
├── Jenkinsfile
├── LICENSE
├── README.md
├── examples
├── Cargo.toml
├── api
│ ├── Cargo.toml
│ ├── index.html
│ └── src
│ │ ├── api.rs
│ │ ├── api
│ │ └── posts.rs
│ │ └── main.rs
├── hello-world
│ ├── Cargo.toml
│ ├── index.html
│ └── src
│ │ ├── main.rs
│ │ ├── pages.rs
│ │ └── pages
│ │ └── index.rs
├── simple-router
│ ├── Cargo.toml
│ ├── index.html
│ └── src
│ │ └── main.rs
└── todos
│ ├── Cargo.toml
│ ├── Trunk.toml
│ ├── index.html
│ ├── public
│ └── todo.css
│ └── src
│ ├── api.rs
│ ├── main.rs
│ └── pages.rs
├── macros
├── Cargo.toml
└── src
│ ├── api.rs
│ ├── lib.rs
│ ├── page.rs
│ └── page
│ └── component.rs
├── rust-toolchain.toml
├── rustfmt.toml
└── src
└── lib.rs
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "cargo" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 | time: "18:00"
13 | timezone: "CET"
14 | open-pull-requests-limit: 10
--------------------------------------------------------------------------------
/.github/workflows/audit-on-push.yml:
--------------------------------------------------------------------------------
1 | name: Security audit
2 | on:
3 | push:
4 | paths:
5 | - '**/Cargo.toml'
6 | - '**/Cargo.lock'
7 | jobs:
8 | security_audit:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v1
12 | - uses: actions-rs/audit-check@v1
13 | with:
14 | token: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/general.yml:
--------------------------------------------------------------------------------
1 | name: Rust
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - '**.md'
9 | pull_request:
10 | branches:
11 | - master
12 | paths-ignore:
13 | - '**.md'
14 | release:
15 | types:
16 | - published
17 |
18 | env:
19 | CARGO_TERM_COLOR: always
20 |
21 | jobs:
22 | test:
23 | name: Test
24 | runs-on: ubuntu-latest
25 | steps:
26 | - uses: actions/checkout@v2
27 | - uses: actions-rs/toolchain@v1
28 | with:
29 | profile: minimal
30 | toolchain: nightly
31 | override: true
32 | - uses: actions-rs/cargo@v1
33 | with:
34 | command: test
35 | args: --all-features
36 |
37 | fmt:
38 | name: Rustfmt
39 | runs-on: ubuntu-latest
40 | steps:
41 | - uses: actions/checkout@v2
42 | - uses: actions-rs/toolchain@v1
43 | with:
44 | toolchain: nightly
45 | override: true
46 | components: rustfmt
47 | - uses: actions-rs/cargo@v1
48 | with:
49 | command: fmt
50 | args: --all -- --check
51 |
52 | clippy:
53 | name: Clippy
54 | runs-on: ubuntu-latest
55 | steps:
56 | - uses: actions/checkout@v2
57 | - uses: actions-rs/toolchain@v1
58 | with:
59 | toolchain: nightly
60 | override: true
61 | components: clippy
62 | - uses: actions-rs/clippy-check@v1
63 | with:
64 | token: ${{ secrets.GITHUB_TOKEN }}
65 | args: -- -D warnings
66 |
67 | coverage:
68 | name: Code coverage
69 | runs-on: ubuntu-latest
70 | steps:
71 | - name: Checkout repository
72 | uses: actions/checkout@v2
73 |
74 | - name: Install nightly toolchain
75 | uses: actions-rs/toolchain@v1
76 | with:
77 | toolchain: nightly
78 | override: true
79 |
80 | - name: Run cargo-tarpaulin
81 | uses: actions-rs/tarpaulin@v0.1
82 | with:
83 | args: --ignore-tests --all-features
84 |
--------------------------------------------------------------------------------
/.github/workflows/scheduled-audit.yml:
--------------------------------------------------------------------------------
1 | name: Security audit
2 | on:
3 | schedule:
4 | - cron: '0 0 * * *'
5 | jobs:
6 | audit:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v1
10 | - uses: actions-rs/audit-check@v1
11 | with:
12 | token: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Linux ###
2 | *~
3 |
4 | # temporary files which can be created if a process still has a handle open of a deleted file
5 | .fuse_hidden*
6 |
7 | # KDE directory preferences
8 | .directory
9 |
10 | # Linux trash folder which might appear on any partition or disk
11 | .Trash-*
12 |
13 | # .nfs files are created when an open file is removed but is still being accessed
14 | .nfs*
15 |
16 | ### macOS ###
17 | # General
18 | .DS_Store
19 | .AppleDouble
20 | .LSOverride
21 |
22 | # Icon must end with two \r
23 | Icon
24 |
25 |
26 | # Thumbnails
27 | ._*
28 |
29 | # Files that might appear in the root of a volume
30 | .DocumentRevisions-V100
31 | .fseventsd
32 | .Spotlight-V100
33 | .TemporaryItems
34 | .Trashes
35 | .VolumeIcon.icns
36 | .com.apple.timemachine.donotpresent
37 |
38 | # Directories potentially created on remote AFP share
39 | .AppleDB
40 | .AppleDesktop
41 | Network Trash Folder
42 | Temporary Items
43 | .apdisk
44 |
45 | ### macOS Patch ###
46 | # iCloud generated files
47 | *.icloud
48 |
49 | ### Rust ###
50 | # Generated by Cargo
51 | # will have compiled files and executables
52 | debug/
53 | target/
54 |
55 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
56 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
57 | Cargo.lock
58 |
59 | # These are backup files generated by rustfmt
60 | **/*.rs.bk
61 |
62 | # MSVC Windows builds of rustc generate these, which store debugging information
63 | *.pdb
64 |
65 | ### Windows ###
66 | # Windows thumbnail cache files
67 | Thumbs.db
68 | Thumbs.db:encryptable
69 | ehthumbs.db
70 | ehthumbs_vista.db
71 |
72 | # Dump file
73 | *.stackdump
74 |
75 | # Folder config file
76 | [Dd]esktop.ini
77 |
78 | # Recycle Bin used on file shares
79 | $RECYCLE.BIN/
80 |
81 | # Windows Installer files
82 | *.cab
83 | *.msi
84 | *.msix
85 | *.msm
86 | *.msp
87 |
88 | # Windows shortcuts
89 | *.lnk
90 |
91 | # Custom
92 | _ahecha_debug.rs
93 | dist/
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "ahecha"
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 | [workspace]
8 | members = [
9 | "macros",
10 | ]
11 | exclude = [
12 | "examples",
13 | ]
14 | resolver = "2"
15 |
16 | [dependencies]
17 | ahecha-macros = { path = "./macros" }
18 | dioxus = { git = "https://github.com/dioxuslabs/dioxus" }
19 | dioxus-web = { git = "https://github.com/dioxuslabs/dioxus" }
20 | hex = "0.4.3"
21 | matchit = "0.7.0"
22 | sha2 = "0.10.6"
23 | tracing = "0.1.36"
24 |
25 | [target.'cfg(target_arch = "wasm32")'.dependencies]
26 | gloo = "0.8.0"
27 | wasm-bindgen = "0.2.83"
28 | web-sys = { version = "0.3.60", features = ["Document", "History", "Location", "Window"] }
29 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | pipeline {
2 | agent none
3 | stages {
4 | stage('build') {
5 | agent {
6 | docker {
7 | image 'rust:nightly-bullseye-slim'
8 | registryUrl: 'https://ghcr.io/rust-lang'
9 | }
10 | }
11 | steps {
12 | echo "building"
13 | sh "cargo build"
14 | }
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ahecha
2 |
3 | Ahecha is a isomorphic Rust framework, built on top of [Axum](https://github.com/tokio-rs/axum) and
4 | [Dioxus](https://dioxuslabs.com). And gives you the best developer experience with all the features
5 | you need server and client side rendering, route prefetch, and more.
6 |
7 |
--------------------------------------------------------------------------------
/examples/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | members = ["*"]
3 | exclude = ["target"]
--------------------------------------------------------------------------------
/examples/api/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "api-example"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 |
9 | [dependencies]
10 | axum = { version = "0.5.16", features = ["macros"] }
11 | dioxus = { version = "0.2.4", features = ["web", "ssr"] }
12 | ahecha = { path = "../../" }
13 | tokio = { version = "1.21.1", features = ["macros", "rt", "rt-multi-thread"] }
14 |
--------------------------------------------------------------------------------
/examples/api/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/api/src/api.rs:
--------------------------------------------------------------------------------
1 | pub mod posts;
2 |
--------------------------------------------------------------------------------
/examples/api/src/api/posts.rs:
--------------------------------------------------------------------------------
1 | use axum::Json;
2 |
3 | #[::ahecha::route(GET, "/")]
4 | pub async fn posts() -> Json {
5 | Json(
6 | r#"[
7 | {
8 | "title": "Post #1",
9 | "content": "Content #1",
10 | },
11 | {
12 | "title": "Post #2",
13 | "content": "Content #2",
14 | }
15 | ]"#
16 | .to_string(),
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/examples/api/src/main.rs:
--------------------------------------------------------------------------------
1 | // This macro cleans the target directory before each compilation,
2 | // to prevent orphan files that could cause to generate missing routes.
3 | ::ahecha::monkey_path_clean!();
4 |
5 | mod api;
6 |
7 | #[cfg(not(target_arch = "wasm32"))]
8 | async fn server(router: axum::Router) -> Result<(), String> {
9 | axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
10 | .serve(router.into_make_service())
11 | .await
12 | .unwrap();
13 | Ok(())
14 | }
15 |
16 | #[tokio::main]
17 | async fn main() -> Result<(), String> {
18 | ::ahecha::router!()
19 | }
20 |
--------------------------------------------------------------------------------
/examples/hello-world/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "hello-world-example"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 |
9 | [dependencies]
10 | axum = { version = "0.5.16", features = ["macros"] }
11 | dioxus = { version = "0.2.4", features = ["web", "ssr"] }
12 | ahecha = { path = "../../" }
13 | tokio = { version = "1.21.1", features = ["macros", "rt", "rt-multi-thread"] }
--------------------------------------------------------------------------------
/examples/hello-world/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/hello-world/src/main.rs:
--------------------------------------------------------------------------------
1 | // This macro cleans the target directory before each compilation,
2 | // to prevent orphan files that could cause to generate missing routes.
3 | ::ahecha::monkey_path_clean!();
4 |
5 | mod pages;
6 |
7 | #[cfg(not(target_arch = "wasm32"))]
8 | async fn server(router: axum::Router) -> Result<(), String> {
9 | axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
10 | .serve(router.into_make_service())
11 | .await
12 | .unwrap();
13 | Ok(())
14 | }
15 |
16 | #[tokio::main]
17 | async fn main() -> Result<(), String> {
18 | ::ahecha::router!()
19 | }
20 |
--------------------------------------------------------------------------------
/examples/hello-world/src/pages.rs:
--------------------------------------------------------------------------------
1 | pub mod index;
2 |
--------------------------------------------------------------------------------
/examples/hello-world/src/pages/index.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 |
3 | #[::ahecha::page]
4 | #[allow(non_snake_case)]
5 | pub fn Index(cx: Scope) -> Element {
6 | cx.render(rsx!(
7 | h1 {
8 | class: "text-gray-900 text-xs",
9 | "Hello world!"
10 | }
11 | p {
12 | class: "text-gray-700 text-sm",
13 | "Hello world from router"
14 | }
15 | ))
16 | }
17 |
--------------------------------------------------------------------------------
/examples/simple-router/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "simple-router-example"
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 | ahecha = { path = "../../" }
10 | dioxus = { git = "https://github.com/dioxuslabs/dioxus" }
11 | dioxus-web = { git = "https://github.com/dioxuslabs/dioxus" }
12 | log = "0.4.17"
13 | tracing-wasm = "0.2.1"
14 | wasm-logger = "0.2.0"
15 |
--------------------------------------------------------------------------------
/examples/simple-router/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Ahecha
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/simple-router/src/main.rs:
--------------------------------------------------------------------------------
1 | use ahecha::*;
2 | use dioxus::prelude::*;
3 |
4 | fn main() {
5 | wasm_logger::init(wasm_logger::Config::new(log::Level::Debug));
6 | tracing_wasm::set_as_global_default();
7 | dioxus_web::launch(app);
8 | }
9 |
10 | fn app(cx: Scope) -> Element {
11 | cx.render(rsx! {
12 | BrowserRouter {
13 | Routes {
14 | Route {
15 | path: "/posts",
16 | element: Promo,
17 | }
18 | Route {
19 | path: "/posts/*p",
20 | element: Promo,
21 | }
22 | }
23 | Routes {
24 | Layout {
25 | Route {
26 | path: "/",
27 | element: Home,
28 | }
29 | Route {
30 | path: "/posts",
31 | element: Blog,
32 | Route {
33 | path: ":id"
34 | element: Post
35 | }
36 | }
37 | Fallback {
38 | element: NotFound,
39 | }
40 | }
41 | }
42 | }
43 | })
44 | }
45 |
46 | #[allow(non_snake_case)]
47 | #[inline_props]
48 | fn Layout<'a>(cx: Scope<'a>, children: Element<'a>) -> Element<'a> {
49 | cx.render(rsx! {
50 | NavLink { to: "/", "Home" }
51 | " | "
52 | NavLink { to: "/posts", "Posts" }
53 | div {
54 | style: "padding: .75rem;",
55 | children
56 | }
57 | })
58 | }
59 |
60 | #[allow(non_snake_case)]
61 | fn Home(cx: Scope) -> Element {
62 | cx.render(rsx! {
63 | div { "Home" }
64 | })
65 | }
66 |
67 | #[allow(non_snake_case)]
68 | fn Blog(cx: Scope) -> Element {
69 | cx.render(rsx! {
70 | div { "Blog" }
71 | ul {
72 | li {
73 | Link {
74 | to: "/posts/1",
75 | "Post #1"
76 | }
77 | }
78 | }
79 | })
80 | }
81 |
82 | #[allow(non_snake_case)]
83 | fn NotFound(cx: Scope) -> Element {
84 | cx.render(rsx! {
85 | div { "Not Found" }
86 | })
87 | }
88 |
89 | #[allow(non_snake_case)]
90 | fn Post(cx: Scope) -> Element {
91 | cx.render(rsx! {
92 | div { "Post #1" }
93 | })
94 | }
95 |
96 | #[allow(non_snake_case)]
97 | fn Promo(cx: Scope) -> Element {
98 | cx.render(rsx! {
99 | p {
100 | i {
101 | "This is a promo shown only in the Blog section"
102 | }
103 | }
104 | })
105 | }
106 |
--------------------------------------------------------------------------------
/examples/todos/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "todos-example"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 |
9 | [dependencies]
10 | dioxus = { version = "0.2.4", features = ["html", "ssr", "web"] }
11 | ahecha = { path = "../../" }
12 | serde = "1.0.144"
13 | im-rc = "15.1.0"
14 | reqwest = { version = "0.11.12", features = ["json"] }
15 | tracing = "0.1.36"
16 |
17 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
18 | axum = { version = "0.5.16", features = ["macros"] }
19 | tokio = { version = "1.21.1", features = ["macros", "rt", "rt-multi-thread"] }
20 | tower-http = { version = "0.3.4", features = ["fs"] }
21 | uuid = { version = "1.1.2", features = ["serde", "v4"] }
22 |
23 | [target.'cfg(target_arch = "wasm32")'.dependencies]
24 | wasm-logger = "0.2.0"
25 | tracing-wasm = "0.2.1"
26 | uuid = { version = "1.1.2", features = ["serde", "js"] }
27 |
--------------------------------------------------------------------------------
/examples/todos/Trunk.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | # The output dir for all final assets.
3 | dist = "./public/dist"
4 | # The public URL from which assets are to be served.
5 | public_url = "/dist/"
6 |
7 | [watch]
8 | # Paths to watch. The `build.target`'s parent folder is watched by default.
9 | watch = ["./src"]
10 |
11 | [serve]
12 | # The address to serve on.
13 | address = "127.0.0.1"
14 | # The port to serve on.
15 | port = 3001
16 | # Disable auto-reload of the web app.
17 | no_autoreload = true
18 |
19 | [clean]
20 | # The output dir for all final assets.
21 | dist = "./public"
22 |
--------------------------------------------------------------------------------
/examples/todos/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/examples/todos/public/todo.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | pre {
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | button {
9 | margin: 0;
10 | padding: 0;
11 | border: 0;
12 | background: none;
13 | font-size: 100%;
14 | vertical-align: baseline;
15 | font-family: inherit;
16 | font-weight: inherit;
17 | color: inherit;
18 | -webkit-appearance: none;
19 | appearance: none;
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 | }
23 |
24 | body {
25 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
26 | line-height: 1.4em;
27 | background: #f5f5f5;
28 | color: #4d4d4d;
29 | min-width: 230px;
30 | max-width: 550px;
31 | margin: 0 auto;
32 | -webkit-font-smoothing: antialiased;
33 | -moz-osx-font-smoothing: grayscale;
34 | font-weight: 300;
35 | }
36 |
37 | :focus {
38 | outline: 0;
39 | }
40 |
41 | .hidden {
42 | display: none;
43 | }
44 |
45 | .todoapp {
46 | background: #fff;
47 | margin: 130px 0 40px 0;
48 | position: relative;
49 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
50 | }
51 |
52 | .todoapp input::-webkit-input-placeholder {
53 | font-style: italic;
54 | font-weight: 300;
55 | color: #e6e6e6;
56 | }
57 |
58 | .todoapp input::-moz-placeholder {
59 | font-style: italic;
60 | font-weight: 300;
61 | color: #e6e6e6;
62 | }
63 |
64 | .todoapp input::input-placeholder {
65 | font-style: italic;
66 | font-weight: 300;
67 | color: #e6e6e6;
68 | }
69 |
70 | .todoapp h1 {
71 | position: absolute;
72 | top: -155px;
73 | width: 100%;
74 | font-size: 100px;
75 | font-weight: 100;
76 | text-align: center;
77 | color: rgba(175, 47, 47, 0.15);
78 | -webkit-text-rendering: optimizeLegibility;
79 | -moz-text-rendering: optimizeLegibility;
80 | text-rendering: optimizeLegibility;
81 | }
82 |
83 | .new-todo,
84 | .edit {
85 | position: relative;
86 | margin: 0;
87 | width: 100%;
88 | font-size: 24px;
89 | font-family: inherit;
90 | font-weight: inherit;
91 | line-height: 1.4em;
92 | border: 0;
93 | color: inherit;
94 | padding: 6px;
95 | border: 1px solid #999;
96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
97 | box-sizing: border-box;
98 | -webkit-font-smoothing: antialiased;
99 | -moz-osx-font-smoothing: grayscale;
100 | }
101 |
102 | .new-todo {
103 | padding: 16px 16px 16px 60px;
104 | border: none;
105 | background: rgba(0, 0, 0, 0.003);
106 | box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
107 | }
108 |
109 | .main {
110 | position: relative;
111 | z-index: 2;
112 | border-top: 1px solid #e6e6e6;
113 | }
114 |
115 | .toggle-all {
116 | text-align: center;
117 | border: none;
118 | /* Mobile Safari */
119 | opacity: 0;
120 | position: absolute;
121 | }
122 |
123 | .toggle-all+label {
124 | width: 60px;
125 | height: 34px;
126 | font-size: 0;
127 | position: absolute;
128 | top: -52px;
129 | left: -13px;
130 | -webkit-transform: rotate(90deg);
131 | transform: rotate(90deg);
132 | }
133 |
134 | .toggle-all+label:before {
135 | content: '❯';
136 | font-size: 22px;
137 | color: #e6e6e6;
138 | padding: 10px 27px 10px 27px;
139 | }
140 |
141 | .toggle-all:checked+label:before {
142 | color: #737373;
143 | }
144 |
145 | .todo-list {
146 | margin: 0;
147 | padding: 0;
148 | list-style: none;
149 | }
150 |
151 | .todo-list li {
152 | position: relative;
153 | font-size: 24px;
154 | border-bottom: 1px solid #ededed;
155 | }
156 |
157 | .todo-list li:last-child {
158 | border-bottom: none;
159 | }
160 |
161 | .todo-list li.editing {
162 | border-bottom: none;
163 | padding: 0;
164 | }
165 |
166 | .todo-list li.editing .edit {
167 | display: block;
168 | width: 506px;
169 | padding: 12px 16px;
170 | margin: 0 0 0 43px;
171 | }
172 |
173 | .todo-list li.editing .view {
174 | display: none;
175 | }
176 |
177 | .todo-list li .toggle {
178 | text-align: center;
179 | width: 40px;
180 | /* auto, since non-WebKit browsers doesn't support input styling */
181 | height: auto;
182 | position: absolute;
183 | top: 0;
184 | bottom: 0;
185 | margin: auto 0;
186 | border: none;
187 | /* Mobile Safari */
188 | -webkit-appearance: none;
189 | appearance: none;
190 | }
191 |
192 | .todo-list li .toggle {
193 | opacity: 0;
194 | }
195 |
196 | .todo-list li .toggle+label {
197 | /*
198 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
199 | 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/
200 | */
201 | 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%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
202 | background-repeat: no-repeat;
203 | background-position: center left;
204 | }
205 |
206 | .todo-list li .toggle:checked+label {
207 | 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%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
208 | }
209 |
210 | .todo-list li label {
211 | word-break: break-all;
212 | padding: 15px 15px 15px 60px;
213 | display: block;
214 | line-height: 1.2;
215 | transition: color 0.4s;
216 | }
217 |
218 | .todo-list li.completed label {
219 | color: #d9d9d9;
220 | text-decoration: line-through;
221 | }
222 |
223 | .todo-list li .destroy {
224 | display: none;
225 | position: absolute;
226 | top: 0;
227 | right: 10px;
228 | bottom: 0;
229 | width: 40px;
230 | height: 40px;
231 | margin: auto 0;
232 | font-size: 30px;
233 | color: #cc9a9a;
234 | margin-bottom: 11px;
235 | transition: color 0.2s ease-out;
236 | }
237 |
238 | .todo-list li .destroy:hover {
239 | color: #af5b5e;
240 | }
241 |
242 | .todo-list li .destroy:after {
243 | content: '×';
244 | }
245 |
246 | .todo-list li:hover .destroy {
247 | display: block;
248 | }
249 |
250 | .todo-list li .edit {
251 | display: none;
252 | }
253 |
254 | .todo-list li.editing:last-child {
255 | margin-bottom: -1px;
256 | }
257 |
258 | .footer {
259 | color: #777;
260 | padding: 10px 15px;
261 | height: 20px;
262 | text-align: center;
263 | border-top: 1px solid #e6e6e6;
264 | }
265 |
266 | .footer:before {
267 | content: '';
268 | position: absolute;
269 | right: 0;
270 | bottom: 0;
271 | left: 0;
272 | height: 50px;
273 | overflow: hidden;
274 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2);
275 | }
276 |
277 | .todo-count {
278 | float: left;
279 | text-align: left;
280 | }
281 |
282 | .todo-count strong {
283 | font-weight: 300;
284 | }
285 |
286 | .filters {
287 | margin: 0;
288 | padding: 0;
289 | list-style: none;
290 | position: absolute;
291 | right: 0;
292 | left: 0;
293 | }
294 |
295 | .filters li {
296 | display: inline;
297 | }
298 |
299 | .filters li a {
300 | color: inherit;
301 | margin: 3px;
302 | padding: 3px 7px;
303 | text-decoration: none;
304 | border: 1px solid transparent;
305 | border-radius: 3px;
306 | }
307 |
308 | .filters li a:hover {
309 | border-color: rgba(175, 47, 47, 0.1);
310 | }
311 |
312 | .filters li a.selected {
313 | border-color: rgba(175, 47, 47, 0.2);
314 | }
315 |
316 | .clear-completed,
317 | html .clear-completed:active {
318 | float: right;
319 | position: relative;
320 | line-height: 20px;
321 | text-decoration: none;
322 | cursor: pointer;
323 | }
324 |
325 | .clear-completed:hover {
326 | text-decoration: underline;
327 | }
328 |
329 | .info {
330 | margin: 65px auto 0;
331 | color: #bfbfbf;
332 | font-size: 10px;
333 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
334 | text-align: center;
335 | }
336 |
337 | .info p {
338 | line-height: 1;
339 | }
340 |
341 | .info a {
342 | color: inherit;
343 | text-decoration: none;
344 | font-weight: 400;
345 | }
346 |
347 | .info a:hover {
348 | text-decoration: underline;
349 | }
350 |
351 |
352 | /*
353 | Hack to remove background from Mobile Safari.
354 | Can't use it globally since it destroys checkboxes in Firefox
355 | */
356 |
357 | @media screen and (-webkit-min-device-pixel-ratio:0) {
358 |
359 | .toggle-all,
360 | .todo-list li .toggle {
361 | background: none;
362 | }
363 |
364 | .todo-list li .toggle {
365 | height: 40px;
366 | }
367 | }
368 |
369 | @media (max-width: 430px) {
370 | .footer {
371 | height: 50px;
372 | }
373 |
374 | .filters {
375 | bottom: 10px;
376 | }
377 | }
--------------------------------------------------------------------------------
/examples/todos/src/api.rs:
--------------------------------------------------------------------------------
1 | #[cfg(not(target_arch = "wasm32"))]
2 | use axum::{
3 | extract::{Path, Query},
4 | response::IntoResponse,
5 | Extension, Json,
6 | };
7 | use serde::{Deserialize, Serialize};
8 | use uuid::Uuid;
9 |
10 | #[cfg(not(target_arch = "wasm32"))]
11 | use crate::Db;
12 |
13 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
14 | pub struct Todo {
15 | pub id: Uuid,
16 | pub text: String,
17 | pub completed: bool,
18 | }
19 |
20 | #[derive(Serialize, Deserialize)]
21 | pub enum JsonResponse {
22 | Created,
23 | Deleted,
24 | }
25 |
26 | #[derive(Serialize, Deserialize)]
27 | pub enum JsonError {
28 | NotFound,
29 | }
30 |
31 | #[cfg(not(target_arch = "wasm32"))]
32 | impl IntoResponse for JsonError {
33 | fn into_response(self) -> axum::response::Response {
34 | Json(self).into_response()
35 | }
36 | }
37 |
38 | // The query parameters for todos index
39 | #[derive(Debug, Deserialize, Default)]
40 | pub struct Pagination {
41 | pub offset: Option,
42 | pub limit: Option,
43 | }
44 |
45 | #[::ahecha::route(GET, "/todos")]
46 | pub async fn index(
47 | pagination: Option>,
48 | Extension(db): Extension,
49 | ) -> Json> {
50 | let todos = db.read().unwrap();
51 |
52 | let Query(pagination) = pagination.unwrap_or_default();
53 |
54 | let todos = todos
55 | .values()
56 | .skip(pagination.offset.unwrap_or(0))
57 | .take(pagination.limit.unwrap_or(usize::MAX))
58 | .cloned()
59 | .collect::>();
60 |
61 | Json(todos)
62 | }
63 |
64 | #[derive(Debug, Serialize, Deserialize)]
65 | pub struct CreateTodo {
66 | pub text: String,
67 | }
68 |
69 | #[::ahecha::route(POST, "/todos")]
70 | pub async fn create(Extension(db): Extension, Json(input): Json) -> Json {
71 | let todo = Todo {
72 | id: Uuid::new_v4(),
73 | text: input.text,
74 | completed: false,
75 | };
76 |
77 | db.write().unwrap().insert(todo.id, todo.clone());
78 |
79 | Json(todo)
80 | }
81 |
82 | #[::ahecha::route(DELETE, "/todos/:id")]
83 | pub async fn delete(
84 | Path(id): Path,
85 | Extension(db): Extension,
86 | ) -> Result, JsonError> {
87 | if db.write().unwrap().remove(&id).is_some() {
88 | Ok(Json(JsonResponse::Deleted))
89 | } else {
90 | Err(JsonError::NotFound)
91 | }
92 | }
93 |
94 | #[derive(Debug, Serialize, Deserialize)]
95 | pub struct UpdateTodo {
96 | pub text: Option,
97 | pub completed: Option,
98 | }
99 |
100 | #[::ahecha::route(PATCH, "/todos/:id")]
101 | pub async fn update(
102 | Path(id): Path,
103 | Extension(db): Extension,
104 | Json(input): Json,
105 | ) -> Result, JsonError> {
106 | let mut todo = db
107 | .read()
108 | .unwrap()
109 | .get(&id)
110 | .cloned()
111 | .ok_or(JsonError::NotFound)?;
112 |
113 | if let Some(text) = input.text {
114 | todo.text = text;
115 | }
116 |
117 | if let Some(completed) = input.completed {
118 | todo.completed = completed;
119 | }
120 |
121 | db.write().unwrap().insert(todo.id, todo.clone());
122 |
123 | Ok(Json(todo))
124 | }
125 |
--------------------------------------------------------------------------------
/examples/todos/src/main.rs:
--------------------------------------------------------------------------------
1 | // This macro cleans the target directory before each compilation,
2 | // to prevent orphan files that could cause to generate missing routes.
3 | ::ahecha::monkey_path_clean!();
4 |
5 | mod api;
6 | mod pages;
7 |
8 | #[cfg(not(target_arch = "wasm32"))]
9 | type Db = std::sync::Arc>>;
10 |
11 | #[cfg(not(target_arch = "wasm32"))]
12 | async fn server(router: axum::Router) -> Result<(), String> {
13 | async fn handle_error(_err: std::io::Error) -> impl axum::response::IntoResponse {
14 | (
15 | axum::http::StatusCode::INTERNAL_SERVER_ERROR,
16 | "Something went wrong...",
17 | )
18 | }
19 |
20 | let db = Db::default();
21 |
22 | axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
23 | .serve(
24 | router
25 | .fallback(
26 | axum::routing::get_service(tower_http::services::ServeDir::new("./public"))
27 | .handle_error(handle_error),
28 | )
29 | .layer(axum::extract::Extension(db))
30 | .into_make_service(),
31 | )
32 | .await
33 | .unwrap();
34 |
35 | Ok(())
36 | }
37 |
38 | #[cfg(target_arch = "wasm32")]
39 | fn client() {
40 | wasm_logger::init(wasm_logger::Config::new(tracing::log::Level::Debug));
41 | tracing_wasm::set_as_global_default();
42 | dioxus::web::launch(pages::Index);
43 | }
44 |
45 | #[cfg(not(target_arch = "wasm32"))]
46 | #[tokio::main]
47 | async fn main() -> Result<(), String> {
48 | ::ahecha::router!()
49 | }
50 |
51 | #[cfg(target_arch = "wasm32")]
52 | fn main() {
53 | client()
54 | }
55 |
--------------------------------------------------------------------------------
/examples/todos/src/pages.rs:
--------------------------------------------------------------------------------
1 | use dioxus::{events::KeyCode, prelude::*};
2 | use uuid::Uuid;
3 |
4 | use crate::api::{CreateTodo, Todo, UpdateTodo};
5 |
6 | #[derive(PartialEq, Eq)]
7 | pub enum FilterState {
8 | All,
9 | Active,
10 | Completed,
11 | }
12 |
13 | #[::ahecha::page("/")]
14 | #[allow(non_snake_case)]
15 | pub fn Index(cx: Scope) -> Element {
16 | let filter = use_state(&cx, || FilterState::All);
17 | let draft = use_state(&cx, || "".to_string());
18 | let touch = use_state(&cx, || None);
19 |
20 | let todos = use_future(&cx, &touch.clone(), |_| async move {
21 | reqwest::Client::new()
22 | .get("http://localhost:3000/api/todos")
23 | .send()
24 | .await
25 | .unwrap()
26 | .json::>()
27 | .await
28 | .unwrap()
29 | })
30 | .value()?;
31 |
32 | // Filter the todos based on the filter state
33 | let mut filtered_todos = todos
34 | .iter()
35 | .filter(|item| match **filter {
36 | FilterState::All => true,
37 | FilterState::Active => !item.completed,
38 | FilterState::Completed => item.completed,
39 | })
40 | .map(|f| f.id)
41 | .collect::>();
42 | filtered_todos.sort_unstable();
43 |
44 | let show_clear_completed = todos.iter().any(|todo| todo.completed);
45 | let items_left = filtered_todos.len();
46 | let item_text = match items_left {
47 | 1 => "item",
48 | _ => "items",
49 | };
50 |
51 | cx.render(rsx!{
52 | section { class: "todoapp",
53 | div {
54 | header { class: "header",
55 | h1 {"todos"}
56 | input {
57 | class: "new-todo",
58 | placeholder: "What needs to be done?",
59 | value: "{draft}",
60 | autofocus: "true",
61 | oninput: move |evt| draft.set(evt.value.clone()),
62 | onkeydown: {
63 | let draft = draft.clone();
64 | let touch = touch.clone();
65 |
66 | move |evt| {
67 | let draft = draft.clone();
68 | let touch = touch.clone();
69 |
70 | if evt.key_code == KeyCode::Enter && !draft.is_empty() {
71 | cx.spawn(async move {
72 | let todo = reqwest::Client::new().post("http://localhost:3000/api/todos")
73 | .json(&CreateTodo {
74 | text: draft.to_string(),
75 | })
76 | .send()
77 | .await
78 | .unwrap()
79 | .json::()
80 | .await
81 | .unwrap();
82 |
83 | touch.set(Some(todo.id));
84 | draft.set("".to_string());
85 | });
86 | }
87 | }
88 | }
89 | }
90 | }
91 | ul { class: "todo-list",
92 | filtered_todos.iter().map(|id| rsx!(TodoEntry { key: "{id}", id: *id, todos: todos.to_vec() }))
93 | }
94 | (!todos.is_empty()).then(|| rsx!(
95 | footer { class: "footer",
96 | span { class: "todo-count",
97 | strong {"{items_left} "}
98 | span {"{item_text} left"}
99 | }
100 | ul { class: "filters",
101 | li { class: "All", a { onclick: move |_| filter.set(FilterState::All), "All" }}
102 | li { class: "Active", a { onclick: move |_| filter.set(FilterState::Active), "Active" }}
103 | li { class: "Completed", a { onclick: move |_| filter.set(FilterState::Completed), "Completed" }}
104 | }
105 | show_clear_completed.then(|| rsx!(
106 | button {
107 | class: "clear-completed",
108 | onclick: {
109 | let touch = touch.clone();
110 | let todos = todos.clone();
111 |
112 | move |_| {
113 | let touch = touch.clone();
114 | let todos = todos.clone();
115 |
116 | cx.spawn(async move {
117 | for todo in todos.iter().filter(|f| f.completed).collect::>() {
118 | reqwest::Client::new().delete(format!("http://localhost:3000/api/todos/{}", &todo.id))
119 | .send()
120 | .await
121 | .unwrap();
122 | }
123 |
124 | touch.set(None);
125 | });
126 | }
127 | },
128 | "Clear completed"
129 | }
130 | ))
131 | }
132 | ))
133 | }
134 | }
135 | footer { class: "info",
136 | p {"Double-click to edit a todo"}
137 | p { "Created by ", a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }}
138 | p { "Part of ", a { href: "http://todomvc.com", "TodoMVC" }}
139 | }
140 | })
141 | }
142 |
143 | #[derive(Props, PartialEq)]
144 | pub struct TodoEntryProps {
145 | todos: Vec,
146 | id: Uuid,
147 | }
148 |
149 | #[allow(non_snake_case)]
150 | pub fn TodoEntry(cx: Scope) -> Element {
151 | let is_editing = use_state(&cx, || false);
152 |
153 | let todos = &cx.props.todos;
154 | let todo = &todos.iter().find(|f| f.id == cx.props.id)?;
155 | let completed = if todo.completed { "completed" } else { "" };
156 | let editing = if **is_editing { "editing" } else { "" };
157 | let id = cx.props.id;
158 |
159 | let update = |id: Uuid, text: Option, completed: Option| async move {
160 | reqwest::Client::new()
161 | .patch(format!("http://localhost:3000/api/todos/{}", &id))
162 | .json(&UpdateTodo { completed, text })
163 | .send()
164 | .await
165 | .unwrap();
166 | };
167 |
168 | cx.render(rsx! {
169 | li {
170 | class: "{completed} {editing}",
171 | div { class: "view",
172 | input {
173 | class: "toggle",
174 | r#type: "checkbox",
175 | id: "cbg-{todo.id}",
176 | checked: "{todo.completed}",
177 | oninput: {
178 | move |evt| {
179 | let value = evt.value.parse().ok();
180 | cx.spawn(async move {
181 | update(id, None, value).await;
182 | });
183 | }
184 | }
185 | }
186 |
187 | label {
188 | r#for: "cbg-{todo.id}",
189 | onclick: move |_| is_editing.set(true),
190 | prevent_default: "onclick",
191 | "{todo.text}"
192 | }
193 | }
194 | is_editing.then(|| rsx!{
195 | input {
196 | class: "edit",
197 | value: "{todo.text}",
198 | oninput: {
199 | move |evt| {
200 | let value = evt.value.parse().ok();
201 | cx.spawn(async move {
202 | update(id, None, value).await;
203 | });
204 | }
205 | },
206 | autofocus: "true",
207 | onfocusout: move |_| is_editing.set(false),
208 | onkeydown: move |evt| {
209 | match evt.key_code {
210 | KeyCode::Enter | KeyCode::Escape | KeyCode::Tab => is_editing.set(false),
211 | _ => {}
212 | }
213 | },
214 | }
215 | })
216 | }
217 | })
218 | }
219 |
--------------------------------------------------------------------------------
/macros/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "ahecha-macros"
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 | [lib]
8 | proc-macro = true
9 |
10 | [dependencies]
11 | hex = "0.4.3"
12 | prettyplease = "0.1.19"
13 | proc-macro-error = "1.0.4"
14 | proc-macro2 = "1.0.43"
15 | quote = "1.0.21"
16 | serde = { version = "1.0.144", features = ["derive"] }
17 | serde_json = "1.0.85"
18 | sha2 = "0.10.5"
19 | syn = { version = "1.0.99", features = ["full"] }
20 |
--------------------------------------------------------------------------------
/macros/src/api.rs:
--------------------------------------------------------------------------------
1 | use std::fs::create_dir_all;
2 |
3 | use proc_macro_error::abort_call_site;
4 | use quote::{quote, ToTokens};
5 | use serde::{Deserialize, Serialize};
6 | use syn::{AttributeArgs, ItemFn};
7 |
8 | use crate::{
9 | file_path_from_call_site, module_path_from_call_site, write_to_target, FnArg, Method, Route,
10 | TARGET_PATH,
11 | };
12 |
13 | struct ApiAttributes {
14 | absolute_path: Option,
15 | methods: Vec,
16 | path_segments: Vec,
17 | }
18 |
19 | #[derive(Serialize, Deserialize, Debug)]
20 | pub(crate) enum ReturnTy {
21 | Json,
22 | Result,
23 | Redirect,
24 | }
25 |
26 | #[derive(Serialize, Deserialize, Debug)]
27 | pub(crate) struct ApiRoute {
28 | pub(crate) args: Vec,
29 | pub(crate) ident: String,
30 | pub(crate) methods: Vec,
31 | pub(crate) module_path: String,
32 | pub(crate) path: String,
33 | pub(crate) return_ty: ReturnTy,
34 | }
35 |
36 | impl ToTokens for ApiRoute {
37 | fn to_tokens(&self, tokens: &mut quote::__private::TokenStream) {
38 | let route_path = &self.path;
39 | let module_path: quote::__private::TokenStream =
40 | format!("{}::{}", &self.module_path, &self.ident)
41 | .parse::()
42 | .unwrap();
43 | let mut routing = vec![];
44 |
45 | for method in self.methods.iter() {
46 | routing.push(if routing.is_empty() {
47 | match method {
48 | Method::Delete => quote!( axum::routing::delete( #module_path ) ),
49 | Method::Get => quote!( axum::routing::get( #module_path ) ),
50 | Method::Patch => quote!( axum::routing::patch( #module_path ) ),
51 | Method::Post => quote!( axum::routing::post( #module_path ) ),
52 | Method::Put => quote!( axum::routing::put( #module_path ) ),
53 | }
54 | } else {
55 | match method {
56 | Method::Delete => quote!( .delete( #module_path ) ),
57 | Method::Get => quote!( .get( #module_path ) ),
58 | Method::Patch => quote!( .patch( #module_path ) ),
59 | Method::Post => quote!( .post( #module_path ) ),
60 | Method::Put => quote!( .put( #module_path ) ),
61 | }
62 | });
63 | }
64 |
65 | quote!(
66 | .route(#route_path, #(#routing)*)
67 | )
68 | .to_tokens(tokens);
69 | }
70 | }
71 |
72 | fn parse_attributes(attr: AttributeArgs) -> ApiAttributes {
73 | let mut methods = vec![];
74 | let mut absolute_path = None;
75 | let mut path_segments = vec![];
76 |
77 | for meta in attr.iter() {
78 | match meta {
79 | syn::NestedMeta::Meta(meta) => match meta {
80 | syn::Meta::Path(path) => {
81 | if let Some(ident) = path.get_ident() {
82 | let ident_str = ident.to_string();
83 | match ident_str.as_str() {
84 | "DELETE" => methods.push(Method::Delete),
85 | "GET" => methods.push(Method::Get),
86 | "PATCH" => methods.push(Method::Patch),
87 | "POST" => methods.push(Method::Post),
88 | "PUT" => methods.push(Method::Put),
89 | _ => {
90 | dbg!(&meta);
91 | todo!();
92 | }
93 | }
94 | }
95 | }
96 | syn::Meta::List(_) => {
97 | dbg!(&meta);
98 | todo!();
99 | }
100 | syn::Meta::NameValue(_) => {
101 | dbg!(&meta);
102 | todo!();
103 | }
104 | },
105 | syn::NestedMeta::Lit(lit) => match lit {
106 | syn::Lit::Str(value) => {
107 | if value.value().starts_with("~/") {
108 | path_segments = value
109 | .value()
110 | .trim_start_matches("~/")
111 | .split("/")
112 | .map(|s| s.to_string())
113 | .collect();
114 | } else {
115 | absolute_path = Some(value.value())
116 | }
117 | }
118 | _ => {
119 | dbg!(&meta);
120 | todo!();
121 | }
122 | },
123 | }
124 | }
125 |
126 | ApiAttributes {
127 | absolute_path,
128 | methods,
129 | path_segments,
130 | }
131 | }
132 |
133 | pub(crate) fn parse(item: ItemFn, attr: AttributeArgs) {
134 | let attr = parse_attributes(attr);
135 | create_dir_all(TARGET_PATH).unwrap();
136 | let ident = item.sig.ident;
137 | let args = item
138 | .sig
139 | .inputs
140 | .iter()
141 | .collect::>()
142 | .to_vec()
143 | .iter()
144 | .filter_map(|arg| match arg {
145 | syn::FnArg::Typed(arg) => {
146 | let ident = match arg.pat.as_ref() {
147 | syn::Pat::Ident(value) => value.ident.to_string(),
148 | syn::Pat::TupleStruct(value) => match value.path.get_ident() {
149 | Some(value_ident) => value_ident.to_string(),
150 | None => {
151 | dbg!(&value);
152 | todo!()
153 | }
154 | },
155 | _ => {
156 | dbg!(&arg.pat);
157 | todo!()
158 | }
159 | };
160 | let arg_ty = &arg.ty;
161 | let ty = quote!(#arg_ty).to_string();
162 | Some(FnArg { ident, ty })
163 | }
164 | syn::FnArg::Receiver(_) => None,
165 | })
166 | .collect::>();
167 | let return_ty = {
168 | match item.sig.output {
169 | syn::ReturnType::Default => {
170 | abort_call_site!("The function must have a return and must be either Json or Result")
171 | }
172 | syn::ReturnType::Type(_, ty) => {
173 | let ty = ty.to_token_stream().to_string();
174 | if ty.starts_with("Json") {
175 | ReturnTy::Json
176 | } else if ty.starts_with("Result") {
177 | ReturnTy::Result
178 | } else if ty.starts_with("Redirect") {
179 | ReturnTy::Redirect
180 | } else {
181 | dbg!(&ty);
182 | abort_call_site!("Only Json and Result are supported return types");
183 | }
184 | }
185 | }
186 | };
187 | let file_path = file_path_from_call_site();
188 | let parts = file_path.split("src/").collect::>();
189 | let file_path = parts.get(1).unwrap().trim_end_matches(".rs");
190 | let path = {
191 | let path = match attr.absolute_path {
192 | Some(path) => path,
193 | None => file_path.trim_end_matches("index").to_owned(),
194 | };
195 |
196 | if attr.path_segments.is_empty() {
197 | path
198 | } else {
199 | let mut path = path.trim_end_matches("/").split("/").collect::>();
200 | let _ = path.pop();
201 | format!(
202 | "{}/{}",
203 | path.join("/").replace("//", "/"),
204 | attr.path_segments.join("/")
205 | )
206 | }
207 | };
208 | let module_path = module_path_from_call_site();
209 | let route = ApiRoute {
210 | args,
211 | ident: ident.to_string(),
212 | methods: attr.methods,
213 | module_path,
214 | path: format!(
215 | "/api/{}",
216 | path
217 | .trim_start_matches("/")
218 | .trim_start_matches("api/")
219 | .trim_end_matches("/")
220 | ),
221 | return_ty,
222 | };
223 | write_to_target(
224 | "route",
225 | &format!("{}-{}", &route.module_path, &route.ident),
226 | Route::Api(route),
227 | );
228 | }
229 |
--------------------------------------------------------------------------------
/macros/src/lib.rs:
--------------------------------------------------------------------------------
1 | #![feature(proc_macro_span)]
2 | use std::fs::{create_dir_all, read_dir, read_to_string, remove_dir_all, write};
3 |
4 | use api::ApiRoute;
5 | use page::{DynamicPageRoute, StaticPageRoute};
6 | use proc_macro::{Span, TokenStream};
7 | use proc_macro_error::proc_macro_error;
8 | use quote::{quote, ToTokens};
9 | use serde::{Deserialize, Serialize};
10 | use syn::{parse_macro_input, AttributeArgs, Ident, ItemFn};
11 |
12 | mod api;
13 | mod page;
14 |
15 | const TARGET_PATH: &'static str = "target/ahecha";
16 |
17 | #[derive(Serialize, Deserialize, PartialEq, Debug)]
18 | enum RenderStrategy {
19 | CSR,
20 | SSR,
21 | }
22 |
23 | #[derive(Serialize, Deserialize, PartialEq, Debug)]
24 | enum Method {
25 | Delete,
26 | Get,
27 | Patch,
28 | Post,
29 | Put,
30 | }
31 |
32 | #[derive(Serialize, Deserialize, Debug)]
33 | struct FnArg {
34 | ident: String,
35 | ty: String,
36 | }
37 |
38 | impl ToTokens for FnArg {
39 | fn to_tokens(&self, tokens: &mut quote::__private::TokenStream) {
40 | let ident = Ident::new(&self.ident, Span::call_site().into());
41 | let ty = self
42 | .ty
43 | .clone()
44 | .parse::()
45 | .unwrap();
46 | quote!(#ident: #ty).to_tokens(tokens);
47 | }
48 | }
49 |
50 | #[derive(Serialize, Deserialize, Debug)]
51 | enum Route {
52 | Api(ApiRoute),
53 | DynamicPage(DynamicPageRoute),
54 | StaticPage(StaticPageRoute),
55 | }
56 |
57 | impl ToTokens for Route {
58 | fn to_tokens(&self, tokens: &mut quote::__private::TokenStream) {
59 | match self {
60 | Route::Api(t) => quote!(#t).to_tokens(tokens),
61 | Route::DynamicPage(t) => quote!(#t).to_tokens(tokens),
62 | Route::StaticPage(t) => quote!(#t).to_tokens(tokens),
63 | }
64 | }
65 | }
66 |
67 | #[derive(Serialize, Deserialize, Debug)]
68 | struct Layout {
69 | ident: String,
70 | module_path: String,
71 | }
72 |
73 | #[proc_macro_error]
74 | #[proc_macro_attribute]
75 | pub fn layout(_attr: TokenStream, item: TokenStream) -> TokenStream {
76 | {
77 | let item = item.clone();
78 | let item_fn = parse_macro_input!(item as ItemFn);
79 | let layout = Layout {
80 | ident: item_fn.sig.ident.to_string(),
81 | module_path: module_path_from_call_site(),
82 | };
83 | write_to_target("layout", &layout.module_path.clone(), layout);
84 | }
85 | item
86 | }
87 |
88 | #[proc_macro_error]
89 | #[proc_macro_attribute]
90 | pub fn page(attr: TokenStream, item: TokenStream) -> TokenStream {
91 | page::parse(
92 | {
93 | let item = item.clone();
94 | parse_macro_input!(item as ItemFn)
95 | },
96 | parse_macro_input!(attr as AttributeArgs),
97 | );
98 | item
99 | }
100 |
101 | #[proc_macro_error]
102 | #[proc_macro_attribute]
103 | pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
104 | let item_fn = parse_macro_input!(item as ItemFn);
105 | api::parse(item_fn.clone(), parse_macro_input!(attr as AttributeArgs));
106 | quote!( #[cfg(not(target_arch = "wasm32"))] #item_fn ).into()
107 | }
108 |
109 | #[proc_macro_error]
110 | #[proc_macro]
111 | pub fn monkey_path_clean(item: TokenStream) -> TokenStream {
112 | let _ = remove_dir_all(TARGET_PATH);
113 | let _ = create_dir_all(TARGET_PATH);
114 | item
115 | }
116 |
117 | #[proc_macro_error]
118 | #[proc_macro]
119 | pub fn router(_item: TokenStream) -> TokenStream {
120 | create_dir_all(TARGET_PATH).unwrap();
121 | let dir = read_dir(TARGET_PATH).unwrap();
122 | let mut routes = vec![];
123 |
124 | for path in dir {
125 | let path = path.unwrap();
126 | let path_str = path.file_name().into_string().unwrap();
127 | if path_str.ends_with(".json") {
128 | let content = read_to_string(&path.path()).unwrap();
129 | if path_str.starts_with("route-") {
130 | let route: Route = serde_json::from_str(&content).unwrap();
131 | routes.push(route);
132 | }
133 | }
134 | }
135 |
136 | let mut tokens = vec![];
137 |
138 | for route in routes.iter() {
139 | tokens.push(quote!(#route));
140 | }
141 |
142 | let tokens = quote!(
143 | server(
144 | axum::Router::new() #(#tokens)*
145 | ).await
146 | );
147 |
148 | write("../../_ahecha_debug.rs", &tokens.to_string()).unwrap();
149 |
150 | tokens.into()
151 | }
152 |
153 | fn hash_string(input: &str) -> String {
154 | use sha2::{Digest, Sha256};
155 | hex::encode(Sha256::digest(input.as_bytes()))
156 | }
157 |
158 | fn write_to_target(name: &str, string_to_hash: &str, content: C)
159 | where
160 | C: Serialize + std::fmt::Debug,
161 | {
162 | write(
163 | format!(
164 | "{}/{}-{}.json",
165 | TARGET_PATH,
166 | name,
167 | hash_string(string_to_hash)
168 | ),
169 | serde_json::to_string_pretty(&content).unwrap(),
170 | )
171 | .unwrap();
172 | }
173 |
174 | fn file_path_from_call_site() -> String {
175 | let span = Span::call_site();
176 | span.source_file().path().display().to_string()
177 | }
178 |
179 | fn module_path_from_call_site() -> String {
180 | let file_path = file_path_from_call_site();
181 | let parts = file_path.split("src/").collect::>();
182 | let file_path = parts.get(1).unwrap().trim_end_matches(".rs");
183 | format!("crate::{}", file_path.replace('/', "::"))
184 | }
185 |
186 | fn base_module_path(module: &str) -> String {
187 | let mut parts = module.split("::").collect::>();
188 | let _ = parts.remove(parts.len() - 1);
189 | parts.join("::")
190 | }
191 |
--------------------------------------------------------------------------------
/macros/src/page.rs:
--------------------------------------------------------------------------------
1 | mod component;
2 |
3 | use std::fs::{create_dir_all, read_dir, read_to_string};
4 |
5 | use proc_macro2::TokenStream;
6 | use proc_macro_error::{abort_call_site, emit_error};
7 | use quote::{quote, ToTokens, __private::Span};
8 | use serde::{Deserialize, Serialize};
9 | use syn::{AttributeArgs, Ident, ItemFn};
10 |
11 | use self::component::Component;
12 | use crate::{
13 | api::ApiRoute, file_path_from_call_site, module_path_from_call_site, write_to_target, FnArg,
14 | Method, RenderStrategy, Route, TARGET_PATH,
15 | };
16 |
17 | struct PageAttributes {
18 | absolute_path: Option,
19 | path_segments: Vec,
20 | render_strategy: Vec,
21 | server_props: Option,
22 | }
23 |
24 | #[derive(Serialize, Deserialize, Debug)]
25 | pub(crate) struct DynamicPageRoute {
26 | pub(crate) ident: String,
27 | pub(crate) module_path: String,
28 | pub(crate) path: String,
29 | pub(crate) props: Vec,
30 | pub(crate) render_strategy: Vec,
31 | pub(crate) server_props: String,
32 | }
33 |
34 | #[derive(Serialize, Deserialize, Debug)]
35 | pub(crate) struct StaticPageRoute {
36 | pub(crate) ident: String,
37 | pub(crate) module_path: String,
38 | pub(crate) path: String,
39 | pub(crate) render_strategy: Vec,
40 | }
41 |
42 | impl ToTokens for DynamicPageRoute {
43 | fn to_tokens(&self, tokens: &mut quote::__private::TokenStream) {
44 | let route_path = &self.path;
45 |
46 | let api_route = match get_api_route_for(&self.server_props) {
47 | Some(value) => value,
48 | None => abort_call_site!("The route `{}` was not found.`", &self.server_props),
49 | };
50 |
51 | let handler_args = {
52 | let args = &api_route.args;
53 | quote!( #(#args),* )
54 | };
55 | let handler_ident_args = {
56 | let args = api_route
57 | .args
58 | .iter()
59 | .map(|a| a.ident.parse::().unwrap())
60 | .collect::>();
61 | quote!( #(#args),* )
62 | };
63 |
64 | let api_module_path = format!("{}::{}", api_route.module_path, api_route.ident)
65 | .parse::()
66 | .unwrap();
67 |
68 | let props_fields = self.props.iter().map(|p| quote!( #p )).collect::>();
69 |
70 | let props_idents = self
71 | .props
72 | .iter()
73 | .map(|p| {
74 | let ident = Ident::new(p.ident.as_str(), Span::call_site());
75 | quote!( #ident )
76 | })
77 | .collect::>();
78 |
79 | let vdom_init = match api_route.return_ty {
80 | crate::api::ReturnTy::Json => quote!(
81 | let mut vdom = VirtualDom::new_with_props(app, AppProps {
82 | #(#props_idents: res.0. #props_idents),*
83 | });
84 | ),
85 | crate::api::ReturnTy::Result => quote!(
86 | let mut vdom = match res {
87 | Ok(res) => {
88 | VirtualDom::new_with_props(app, AppProps {
89 | #(#props_idents: res. #props_idents),*
90 | });
91 | },
92 | Err(err) => {
93 | #[derive(Props, PartialEq)]
94 | struct ErrorProps {
95 | #(#props_fields,)*
96 | }
97 |
98 | fn error(cx: Scope) -> Element {
99 | cx.render(rsx!(
100 | div {
101 | class: "text-red-500 border-red-500 bg-red-200 p-8"
102 | "{cx.props.error}"
103 | }
104 | ))
105 | }
106 |
107 | VirtualDom::new_with_props(error, ErrorProps {
108 | error: err.to_string(),
109 | });
110 | }
111 | }
112 | ),
113 | crate::api::ReturnTy::Redirect => {
114 | abort_call_site!(
115 | "Only `Result` and `Json` return types are supported at the moment. But the path `{}` has an unsuported return types for the page.",
116 | &api_route.path
117 | )
118 | }
119 | };
120 |
121 | let component = Component::build_recursive_up(self.into());
122 | let use_tokens = component.use_tokens();
123 |
124 | quote!(
125 | .route(#route_path, axum::routing::get(| #handler_args | async move {
126 | use dioxus::prelude::*;
127 | let index_html = include_str!("../public/dist/index.html");
128 |
129 | #[derive(Props, PartialEq)]
130 | struct AppProps {
131 | #(#props_fields,)*
132 | }
133 |
134 | fn app(cx: Scope) -> Element {
135 | let AppProps { #(#props_idents),* } = &cx.props;
136 | #use_tokens
137 | cx.render(rsx!(
138 | #component
139 | ))
140 | }
141 |
142 | let res = #api_module_path ( #handler_ident_args ).await;
143 | #vdom_init
144 |
145 | let _ = vdom.rebuild();
146 | axum::response::Html(
147 | index_html.replace(r#""#, &format!(r#"{}
"#, &dioxus::ssr::render_vdom(&vdom)))
148 | )
149 | }))
150 | )
151 | .to_tokens(tokens);
152 | }
153 | }
154 |
155 | impl ToTokens for StaticPageRoute {
156 | fn to_tokens(&self, tokens: &mut quote::__private::TokenStream) {
157 | let route_path = &self.path;
158 | let component = Component::build_recursive_up(self.into());
159 | let use_tokens = component.use_tokens();
160 |
161 | quote!(
162 | .route(#route_path, axum::routing::get(|| async move {
163 | use dioxus::prelude::*;
164 | let index_html = include_str!("../public/dist/index.html");
165 |
166 | fn app(cx: Scope) -> Element {
167 | #use_tokens
168 | cx.render(rsx!(
169 | #component
170 | ))
171 | }
172 | let mut vdom = VirtualDom::new(app);
173 |
174 | let _ = vdom.rebuild();
175 | axum::response::Html(
176 | index_html.replace(r#""#, &format!(r#"{}
"#, &dioxus::ssr::render_vdom(&vdom)))
177 | )
178 | }))
179 | )
180 | .to_tokens(tokens);
181 | }
182 | }
183 |
184 | fn get_api_route_for(url_path: &str) -> Option {
185 | let dir = read_dir(TARGET_PATH).unwrap();
186 |
187 | for path in dir {
188 | let path = path.unwrap();
189 | let path_str = path.file_name().into_string().unwrap();
190 | if path_str.ends_with(".json") {
191 | let content = read_to_string(&path.path()).unwrap();
192 | if path_str.starts_with("route") {
193 | let value: Route = serde_json::from_str(&content).unwrap();
194 | match value {
195 | Route::Api(value) => {
196 | if &value.path == url_path {
197 | return Some(value);
198 | }
199 | }
200 | _ => (),
201 | }
202 | }
203 | }
204 | }
205 |
206 | None
207 | }
208 |
209 | fn parse_attributes(attr: AttributeArgs) -> PageAttributes {
210 | let mut absolute_path = None;
211 | let mut path_segments = vec![];
212 | let mut server_props = None;
213 |
214 | for meta in attr.iter() {
215 | match meta {
216 | syn::NestedMeta::Meta(meta) => match meta {
217 | syn::Meta::Path(_) => {
218 | dbg!(&meta);
219 | todo!();
220 | }
221 | syn::Meta::List(_) => {
222 | dbg!(&meta);
223 | todo!();
224 | }
225 | syn::Meta::NameValue(named) => {
226 | if let Some(ident) = named.path.get_ident() {
227 | let ident_str = ident.to_string();
228 | if ident_str.as_str() == "server_props" {
229 | match &named.lit {
230 | syn::Lit::Str(value) => {
231 | let path = value.value();
232 | match get_api_route_for(&path) {
233 | Some(r) => {
234 | if !r.methods.contains(&Method::Get) {
235 | emit_error!(
236 | value.span(),
237 | "The specified api route does not support the GET method"
238 | );
239 | }
240 | }
241 | None => emit_error!(value.span(), "Api route not found"),
242 | }
243 | server_props = Some(path)
244 | }
245 | _ => {
246 | dbg!(&meta);
247 | todo!();
248 | }
249 | }
250 | } else {
251 | dbg!(&meta);
252 | todo!();
253 | }
254 | } else {
255 | dbg!(&meta);
256 | todo!();
257 | }
258 | }
259 | },
260 | syn::NestedMeta::Lit(lit) => match lit {
261 | syn::Lit::Str(value) => {
262 | if value.value().starts_with("~/") {
263 | path_segments = value
264 | .value()
265 | .trim_start_matches("~/")
266 | .split("/")
267 | .map(|s| s.to_string())
268 | .collect();
269 | } else {
270 | absolute_path = Some(value.value())
271 | }
272 | }
273 | _ => {
274 | dbg!(&meta);
275 | todo!();
276 | }
277 | },
278 | }
279 | }
280 |
281 | PageAttributes {
282 | absolute_path,
283 | path_segments,
284 | render_strategy: vec![],
285 | server_props,
286 | }
287 | }
288 |
289 | pub(crate) fn parse(item: ItemFn, attr: AttributeArgs) {
290 | create_dir_all(TARGET_PATH).unwrap();
291 | let attr = parse_attributes(attr);
292 | let ident = item.sig.ident;
293 | let props = item.sig.inputs.iter().collect::>()[1..]
294 | .to_vec()
295 | .iter()
296 | .filter_map(|arg| match arg {
297 | syn::FnArg::Typed(arg) => {
298 | let ident = match arg.pat.as_ref() {
299 | syn::Pat::Ident(value) => value.ident.to_string(),
300 | _ => {
301 | dbg!(&arg.pat);
302 | todo!()
303 | }
304 | };
305 | let arg_ty = &arg.ty;
306 | let ty = quote!(#arg_ty).to_string();
307 | Some(FnArg { ident, ty })
308 | }
309 | syn::FnArg::Receiver(_) => None,
310 | })
311 | .collect::>();
312 | let file_path = file_path_from_call_site();
313 | let parts = file_path.split("src/").collect::>();
314 | let file_path = parts.get(1).unwrap().trim_end_matches(".rs");
315 | let path = {
316 | let path = match attr.absolute_path {
317 | Some(path) => path,
318 | None => file_path
319 | .trim_start_matches("pages/")
320 | .trim_end_matches("index")
321 | .to_owned(),
322 | };
323 |
324 | if attr.path_segments.is_empty() {
325 | path
326 | } else {
327 | let mut path = path.trim_end_matches("/").split("/").collect::>();
328 | let _ = path.pop();
329 | format!(
330 | "{}/{}",
331 | path.join("/").replace("//", "/"),
332 | attr.path_segments.join("/")
333 | )
334 | }
335 | };
336 | let module_path = module_path_from_call_site();
337 |
338 | let page = if props.is_empty() && attr.server_props.is_none() {
339 | Route::StaticPage(StaticPageRoute {
340 | ident: ident.to_string(),
341 | module_path: module_path.clone(),
342 | path: format!("/{}", path.trim_start_matches("/").trim_end_matches("/")),
343 | render_strategy: attr.render_strategy,
344 | })
345 | } else {
346 | if props.is_empty() {
347 | abort_call_site!("For dynamic pages component `props` are required. Only #[inline_props] are supported at the moment.");
348 | }
349 |
350 | Route::DynamicPage(DynamicPageRoute {
351 | ident: ident.to_string(),
352 | module_path: module_path.clone(),
353 | props,
354 | path: format!("/{}", path.trim_start_matches("/").trim_end_matches("/")),
355 | render_strategy: attr.render_strategy,
356 | server_props: match attr.server_props {
357 | Some(value) => value,
358 | None => abort_call_site!("For dynamic pages `server_props` is required to be set."),
359 | },
360 | })
361 | };
362 |
363 | write_to_target(
364 | "route",
365 | &format!("{}-{}", &module_path, &ident.to_string()),
366 | page,
367 | );
368 | }
369 |
--------------------------------------------------------------------------------
/macros/src/page/component.rs:
--------------------------------------------------------------------------------
1 | use std::fs::{read_dir, read_to_string};
2 |
3 | use proc_macro2::TokenStream;
4 | use quote::{quote, ToTokens, __private::Span};
5 | use syn::Ident;
6 |
7 | use super::{DynamicPageRoute, StaticPageRoute};
8 | use crate::{base_module_path, Layout, TARGET_PATH};
9 |
10 | #[derive(Clone, Debug)]
11 | pub(crate) struct Component {
12 | pub(crate) alias: String,
13 | pub(crate) children: Vec,
14 | pub(crate) ident: String,
15 | pub(crate) module_path: String,
16 | pub(crate) props: Vec,
17 | }
18 |
19 | impl Component {
20 | pub fn build_recursive_up(page: Component) -> Component {
21 | let dir = read_dir(TARGET_PATH).unwrap();
22 | let mut layouts = vec![];
23 | let mut component_tree: Vec = vec![];
24 |
25 | for file_dir in dir {
26 | let file_dir = file_dir.unwrap();
27 | let path_str = file_dir.file_name().into_string().unwrap();
28 | if path_str.ends_with(".json") {
29 | let content = read_to_string(&file_dir.path()).unwrap();
30 | if path_str.starts_with("layout") {
31 | let value: Layout = serde_json::from_str(&content).unwrap();
32 | layouts.push(value);
33 | }
34 | }
35 | }
36 |
37 | let mut module_path_parts = page
38 | .module_path
39 | .split("::")
40 | .map(|s| s.to_owned())
41 | .collect::>();
42 |
43 | while !module_path_parts.is_empty() {
44 | let module_path = module_path_parts.join("::");
45 |
46 | if let Some(layout) = layouts
47 | .iter()
48 | .find(|l| base_module_path(&l.module_path) == module_path)
49 | {
50 | component_tree.push(layout.into());
51 | }
52 |
53 | let _ = module_path_parts.pop();
54 | }
55 |
56 | let mut component = page;
57 |
58 | for cmp_tree in component_tree.iter() {
59 | component = {
60 | let mut cmp_tree = cmp_tree.clone();
61 | cmp_tree.children.push(component);
62 | cmp_tree
63 | };
64 | }
65 |
66 | component
67 | }
68 |
69 | pub fn use_tokens(&self) -> TokenStream {
70 | let module_path = format!("{}::{}", &self.module_path, &self.ident)
71 | .parse::()
72 | .unwrap();
73 | let alias = &self.alias.parse::().unwrap();
74 | let mut tokens = vec![quote!( use #module_path as #alias; )];
75 | for child in self.children.iter() {
76 | tokens.push(child.use_tokens());
77 | }
78 | quote!( #(#tokens)* )
79 | }
80 | }
81 |
82 | impl ToTokens for Component {
83 | fn to_tokens(&self, tokens: &mut TokenStream) {
84 | let ident = Ident::new(&self.alias, Span::call_site().into());
85 | let props = self
86 | .props
87 | .iter()
88 | .map(|ident| {
89 | let ident = ident.parse::().unwrap();
90 | quote!( #ident: #ident .clone() )
91 | })
92 | .collect::>();
93 | let children = &self.children;
94 |
95 | quote!(
96 | #ident {
97 | #(#props,)*
98 | #(#children)*
99 | }
100 | )
101 | .to_tokens(tokens);
102 | }
103 | }
104 |
105 | impl From<&DynamicPageRoute> for Component {
106 | fn from(item: &DynamicPageRoute) -> Self {
107 | Self {
108 | alias: alias_from_module_path(&item.module_path, &item.ident),
109 | children: vec![],
110 | ident: item.ident.clone(),
111 | module_path: item.module_path.clone(),
112 | props: item.props.iter().map(|a| a.ident.clone()).collect(),
113 | }
114 | }
115 | }
116 |
117 | impl From<&StaticPageRoute> for Component {
118 | fn from(item: &StaticPageRoute) -> Self {
119 | Self {
120 | alias: alias_from_module_path(&item.module_path, &item.ident),
121 | children: vec![],
122 | ident: item.ident.clone(),
123 | module_path: item.module_path.clone(),
124 | props: vec![],
125 | }
126 | }
127 | }
128 |
129 | impl From<&Layout> for Component {
130 | fn from(item: &Layout) -> Self {
131 | Self {
132 | alias: alias_from_module_path(&item.module_path, &item.ident),
133 | children: vec![],
134 | ident: item.ident.clone(),
135 | module_path: item.module_path.clone(),
136 | props: vec![],
137 | }
138 | }
139 | }
140 |
141 | fn alias_from_module_path(module_path: &str, ident: &str) -> String {
142 | let last_part = module_path
143 | .split("::")
144 | .map(uppercase_first)
145 | .collect::>()
146 | .join("");
147 | format!("{}{}", last_part, ident)
148 | }
149 |
150 | fn uppercase_first(data: &str) -> String {
151 | // Uppercase first letter.
152 | let mut result = String::new();
153 | let mut first = true;
154 | for value in data.chars() {
155 | if first {
156 | result.push(value.to_ascii_uppercase());
157 | first = false;
158 | } else {
159 | result.push(value);
160 | }
161 | }
162 | result
163 | }
164 |
--------------------------------------------------------------------------------
/rust-toolchain.toml:
--------------------------------------------------------------------------------
1 | [toolchain]
2 | channel = "nightly"
--------------------------------------------------------------------------------
/rustfmt.toml:
--------------------------------------------------------------------------------
1 | tab_spaces = 2
2 | imports_granularity = "Crate"
3 | group_imports = "StdExternalCrate"
4 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub use ahecha_macros::*;
2 | use dioxus::prelude::*;
3 |
4 | pub trait RouterHistory {
5 | fn back(&mut self);
6 | fn forward(&mut self);
7 | fn push(&mut self, url: impl AsRef);
8 | fn replace(&mut self, url: impl AsRef);
9 | }
10 |
11 | #[derive(Clone)]
12 | pub struct RouterCore {
13 | pub location: Option,
14 | #[cfg(target_arch = "wasm32")]
15 | history: web_sys::History,
16 | }
17 |
18 | impl RouterCore {
19 | pub fn new(location: &Option<&str>) -> Self {
20 | let location = match location {
21 | Some(location) => Some(location.to_string()),
22 | None => {
23 | #[cfg(target_arch = "wasm32")]
24 | match web_sys::window() {
25 | Some(window) => match window.location().pathname() {
26 | Ok(pathname) => Some(pathname),
27 | Err(_) => None,
28 | },
29 | None => None,
30 | }
31 |
32 | #[cfg(not(target_arch = "wasm32"))]
33 | None
34 | }
35 | };
36 |
37 | Self {
38 | location,
39 | #[cfg(target_arch = "wasm32")]
40 | history: web_sys::window().unwrap().history().unwrap(),
41 | }
42 | }
43 | }
44 |
45 | impl RouterHistory for RouterCore {
46 | fn push(&mut self, url: impl AsRef) {
47 | // #[cfg(not(target_arch = "wasm32"))]
48 | {
49 | self.location = Some(url.as_ref().to_owned());
50 | }
51 |
52 | #[cfg(target_arch = "wasm32")]
53 | if let Err(err) =
54 | self
55 | .history
56 | .push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(url.as_ref()))
57 | {
58 | tracing::error!("{:?}", &err);
59 | }
60 | }
61 |
62 | fn replace(&mut self, url: impl AsRef) {
63 | // #[cfg(not(target_arch = "wasm32"))]
64 | {
65 | self.location = Some(url.as_ref().to_owned());
66 | }
67 |
68 | #[cfg(target_arch = "wasm32")]
69 | if let Err(err) =
70 | self
71 | .history
72 | .replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(url.as_ref()))
73 | {
74 | tracing::error!("{:?}", &err);
75 | }
76 | }
77 |
78 | fn back(&mut self) {
79 | todo!()
80 | }
81 |
82 | fn forward(&mut self) {
83 | todo!()
84 | }
85 | }
86 |
87 | #[derive(Clone)]
88 | pub struct RoutesContext {
89 | active_class: String,
90 | base_path: String,
91 | fallback: Option,
92 | router: matchit::Router,
93 | }
94 |
95 | impl RoutesContext {
96 | pub fn new(base_path: &str, active_class: &Option<&str>) -> Self {
97 | Self {
98 | active_class: active_class.map_or_else(|| "active".to_owned(), |s| s.to_owned()),
99 | base_path: base_path.to_owned(),
100 | fallback: None,
101 | router: matchit::Router::new(),
102 | }
103 | }
104 | }
105 |
106 | #[allow(non_snake_case)]
107 | #[inline_props]
108 | pub fn BrowserRouter<'a>(
109 | cx: Scope<'a>,
110 | location: Option<&'a str>,
111 | children: Element<'a>,
112 | ) -> Element<'a> {
113 | use_context_provider(&cx, || RouterCore::new(&location));
114 | // let context = use_context::(&cx)?;
115 | // cx.use_hook(|| {
116 | // // TODO: figure out how to update the location
117 | // #[cfg(target_arch = "wasm32")]
118 | // {
119 | // let context = std::sync::Arc::new(context);
120 | // gloo::events::EventListener::new(&web_sys::window().unwrap(), "popstate", move |_| {
121 | // let location = web_sys::window().unwrap().location().pathname().unwrap();
122 | // tracing::trace!("window.popstate. Location {}", location);
123 | // context.read().replace(location);
124 | // });
125 | // }
126 | // });
127 | cx.render(rsx!(children))
128 | }
129 |
130 | #[allow(non_snake_case)]
131 | #[inline_props]
132 | pub fn Fallback<'a>(cx: Scope<'a>, element: Component, children: Element<'a>) -> Element {
133 | let context = use_context::(&cx)?;
134 | let _ = children;
135 |
136 | cx.use_hook(|| {
137 | tracing::trace!("Registering fallback component");
138 | context.write().fallback = Some(element.clone());
139 | });
140 |
141 | None
142 | }
143 |
144 | #[allow(non_snake_case)]
145 | #[inline_props]
146 | fn InternalError(cx: Scope<'a>, error: String) -> Element {
147 | cx.render(rsx!(
148 | div {
149 | style: r#"
150 | background-color: rgb(254 242 242);
151 | border-radius: .375rem;
152 | padding: 1rem;
153 | "#,
154 | div {
155 | style: "display: flex",
156 | div {
157 | style: "flex-shrink: 0",
158 | svg {
159 | style: r#"
160 | color: rgb(248 113 113);
161 | height: 1.25rem;
162 | width: 1.25rem;
163 | "#,
164 | xmlns:"http://www.w3.org/2000/svg",
165 | view_box: "0 0 20 20",
166 | fill:"currentColor",
167 | path {
168 | fill_rule:"evenodd",
169 | d:"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z",
170 | clip_rule: "evenodd",
171 | }
172 | }
173 | }
174 | div {
175 | style: "margin-left: 0.75rem;",
176 | p {
177 | style: r#"
178 | color: rgb(153 27 27);
179 | font-weight: 500;
180 | font-size: 0.875rem;
181 | line-height: 1.25rem;
182 | margin: 0;
183 | "#,
184 | "{error}"
185 | }
186 | }
187 | }
188 | }
189 | ))
190 | }
191 |
192 | #[allow(non_snake_case)]
193 | #[inline_props]
194 | pub fn Link<'a>(cx: Scope<'a>, to: &'a str, children: Element<'a>) -> Element {
195 | let navigate = use_navigate(&cx);
196 | cx.render(rsx!(a {
197 | href: "{to}",
198 | prevent_default: "onclick",
199 | onclick: move |_| navigate(to),
200 | children
201 | }))
202 | }
203 |
204 | #[allow(non_snake_case)]
205 | #[inline_props]
206 | pub fn NavLink<'a>(
207 | cx: Scope<'a>,
208 | to: &'a str,
209 | active_class: Option<&'a str>,
210 | children: Element<'a>,
211 | ) -> Element {
212 | let router_core = use_context::(&cx)?;
213 | let context = use_context::(&cx);
214 |
215 | if let Some(context) = context {
216 | let active_router = use_state(&cx, || {
217 | let mut active_router = matchit::Router::new();
218 | active_router.insert(to.to_string(), true).unwrap();
219 | active_router
220 | });
221 | let class = if active_router
222 | .get()
223 | .at(
224 | &router_core
225 | .read()
226 | .location
227 | .clone()
228 | .unwrap_or_else(|| "".to_owned()),
229 | )
230 | .is_ok()
231 | {
232 | active_class.map_or_else(|| context.read().active_class.clone(), |s| s.to_owned())
233 | } else {
234 | "".to_owned()
235 | };
236 | let navigate = use_navigate(&cx);
237 | cx.render(rsx!(a {
238 | href: "{to}",
239 | class: "{class}",
240 | prevent_default: "onclick",
241 | onclick: move |_| navigate(to),
242 | children
243 | }))
244 | } else {
245 | cx.render(rsx!(InternalError {
246 | error: "`NavLink` can be used only as a child of `Routes`".to_owned()
247 | }))
248 | }
249 | }
250 |
251 | #[derive(Props)]
252 | pub struct RoutesProps<'a> {
253 | active_class: Option<&'a str>,
254 | #[props(default)]
255 | base_path: &'a str,
256 | children: Element<'a>,
257 | }
258 |
259 | #[allow(non_snake_case)]
260 | pub fn Routes<'a>(cx: Scope<'a, RoutesProps<'a>>) -> Element<'a> {
261 | let RoutesProps {
262 | active_class,
263 | base_path,
264 | children,
265 | } = &cx.props;
266 | use_context_provider(&cx, || RoutesContext::new(base_path, active_class));
267 | let context = use_context::(&cx)?;
268 | let router_core = use_context::(&cx)?;
269 | let base_path = &context.read().base_path;
270 |
271 | cx.render(rsx!(
272 | children
273 |
274 | match router_core.read().location.as_ref() {
275 | Some(location) => match context
276 | .read()
277 | .router
278 | .at(location.as_str().trim_start_matches(base_path))
279 | {
280 | Ok(res) => {
281 | tracing::trace!("A route matched");
282 | let C = res.value.clone();
283 | rsx!( C {} )
284 | }
285 | Err(err) => {
286 | tracing::error!("{:?}", &err);
287 | match context.read().fallback {
288 | Some(Fallback) => rsx!(Fallback {}),
289 | None => rsx!(Fragment {}),
290 | }
291 | }
292 | },
293 | None => {
294 | rsx!(InternalError { error: "`RouterCore.location` is not set".to_owned() })
295 | }
296 | }
297 | ))
298 | }
299 |
300 | #[derive(Clone)]
301 | pub struct RouteContext {
302 | absolute_path: String,
303 | relative_path: String,
304 | }
305 |
306 | #[allow(non_snake_case)]
307 | #[inline_props]
308 | pub fn Route<'a>(
309 | cx: Scope<'a>,
310 | path: &'a str,
311 | children: Element<'a>,
312 | element: Component,
313 | ) -> Element<'a> {
314 | let context = use_context::(&cx)?;
315 | let error = use_state(&cx, || None);
316 |
317 | cx.use_hook(|| {
318 | tracing::trace!("Registering route: {}", path);
319 | let absolute_path = match cx.consume_context::() {
320 | Some(parent_context) => format!(
321 | "{}/{}",
322 | parent_context.absolute_path.trim_end_matches("/"),
323 | path
324 | ),
325 | None => path.to_string(),
326 | };
327 |
328 | let route_context = cx.provide_context(RouteContext {
329 | absolute_path,
330 | relative_path: path.to_string(),
331 | });
332 |
333 | if let Err(err) = context
334 | .write()
335 | .router
336 | .insert(route_context.absolute_path, element.clone())
337 | {
338 | tracing::error!("{:?}", err);
339 | error.set(Some(err.to_string()));
340 | }
341 | });
342 |
343 | cx.render(rsx!(
344 | children
345 | error.get().as_ref().map(|e| rsx!(InternalError { error: e.clone() }))
346 | ))
347 | }
348 |
349 | pub fn use_navigate(cx: &ScopeState) -> impl FnOnce(&str) + '_ + Copy {
350 | let context = use_context::(&cx)
351 | .expect("`use_navigate` can be used in components wraped by `BrowserRouter`");
352 | move |path| context.write().push(path)
353 | }
354 |
--------------------------------------------------------------------------------