├── .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 | --------------------------------------------------------------------------------