├── .envrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── UPGRADE.md ├── dist-workspace.toml ├── examples ├── project │ ├── Cargo.lock │ ├── Cargo.toml │ ├── additional_files │ │ └── foo.json │ ├── assets │ │ └── favicon.ico │ ├── end2end │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── playwright.config.ts │ │ └── tests │ │ │ └── example.spec.ts │ ├── js │ │ └── foo.js │ ├── src │ │ ├── app.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ └── server.rs │ └── style │ │ ├── main.scss │ │ └── tailwind.css └── workspace │ ├── Cargo.lock │ ├── Cargo.toml │ ├── project1 │ ├── app │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── assets │ │ └── favicon.ico │ ├── css │ │ └── main.scss │ ├── front │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ └── server │ │ ├── Cargo.toml │ │ └── src │ │ └── main.rs │ └── project2 │ ├── Cargo.toml │ └── src │ ├── app │ └── mod.rs │ ├── assets │ └── favicon.ico │ ├── lib.rs │ ├── main.rs │ ├── main.scss │ └── server │ └── mod.rs ├── flake.lock ├── flake.nix ├── rust-toolchain.toml └── src ├── command ├── build.rs ├── end2end.rs ├── mod.rs ├── new.rs ├── serve.rs ├── test.rs └── watch.rs ├── compile ├── assets.rs ├── change.rs ├── front.rs ├── hash.rs ├── mod.rs ├── sass.rs ├── server.rs ├── style.rs ├── tailwind.rs └── tests.rs ├── config ├── assets.rs ├── bin_package.rs ├── cli.rs ├── dotenvs.rs ├── end2end.rs ├── hash_file.rs ├── lib_package.rs ├── mod.rs ├── profile.rs ├── project.rs ├── snapshots │ ├── cargo_leptos__config__tests__project.snap │ ├── cargo_leptos__config__tests__workspace.snap │ ├── cargo_leptos__config__tests__workspace_bin_args_project2.snap │ ├── cargo_leptos__config__tests__workspace_in_subdir_project2.snap │ ├── cargo_leptos__config__tests__workspace_project1.snap │ └── cargo_leptos__config__tests__workspace_project2.snap ├── style.rs ├── tailwind.rs ├── tests.rs └── version.rs ├── ext ├── cargo.rs ├── compress.rs ├── exe.rs ├── eyre.rs ├── fs.rs ├── mod.rs ├── path.rs ├── sync.rs ├── tests.rs └── util.rs ├── lib.rs ├── logger.rs ├── main.rs ├── readme.md ├── service ├── mod.rs ├── notify.rs ├── reload.rs ├── serve.rs └── site.rs ├── signal ├── interrupt.rs ├── mod.rs ├── product.rs └── reload.rs ├── snapshots └── cargo_leptos__tests__workspace_build.snap └── tests.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake; 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Test 3 | 4 | on: [push, pull_request] 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | test: 11 | name: Test on ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | steps: 18 | - name: "Checkout repo" 19 | uses: actions/checkout@v4 20 | 21 | - name: "Install wasm32-unknown-unknown" 22 | uses: dtolnay/rust-toolchain@stable 23 | with: 24 | toolchain: "stable" 25 | targets: "wasm32-unknown-unknown" 26 | 27 | - name: "Use rust-cache" 28 | uses: Swatinem/rust-cache@v2 29 | with: 30 | workspaces: | 31 | . 32 | examples/workspace 33 | 34 | - name: "Run cargo test --features=full_tests" 35 | uses: actions-rs/cargo@v1 36 | with: 37 | command: test 38 | args: --features=full_tests 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | node_modules/ 9 | test-results/ 10 | playwright-report/ 11 | playwright/.cache/ 12 | 13 | **/.env 14 | 15 | .DS_Store 16 | 17 | .idea/ 18 | 19 | .direnv/ 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": [ 3 | "./Cargo.toml", 4 | "./examples/project/Cargo.toml", 5 | "./examples/workspace/Cargo.toml" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | [package] 3 | name = "cargo-leptos" 4 | license = "MIT" 5 | repository = "https://github.com/leptos-rs/cargo-leptos" 6 | description = "Build tool for Leptos." 7 | categories = ["development-tools", "wasm", "web-programming"] 8 | keywords = ["leptos"] 9 | version = "0.2.35" 10 | edition = "2021" 11 | rust-version = "1.82.0" 12 | authors = ["Henrik Akesson", "Greg Johnston", "Ben Wishovich"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | [dependencies] 16 | brotli = "8.0" 17 | clap = { version = "4.5.37", features = ["derive"] } 18 | serde = { version = "1.0", features = ["derive"] } 19 | anyhow = "1.0" 20 | color-eyre = "0.6.4" 21 | libflate = "2.1" 22 | tracing = "0.1.41" 23 | cargo-config2 = "0.1.32" 24 | target-lexicon = "0.13.2" 25 | lightningcss = { version = "1.0.0-alpha.65", features = ["browserslist"] } 26 | flexi_logger = "0.30.1" 27 | tokio = { version = "1.44", default-features = false, features = ["full"] } 28 | axum = { version = "0.8.4", features = ["ws"] } 29 | # not using notify 5.0 because it uses Crossbeam which has an issue with tokio 30 | notify-debouncer-full = "0.5.0" 31 | which = "7.0" 32 | cargo_metadata = { version = "0.19.2", features = ["builder"] } 33 | serde_json = "1.0" 34 | wasm-bindgen-cli-support = "0.2.100" 35 | reqwest = { version = "0.12.15", features = [ 36 | "blocking", 37 | "rustls-tls", 38 | "json", 39 | ], default-features = false } 40 | seahash = "4.1" 41 | dirs = "6.0" 42 | camino = "1.1" 43 | dotenvy = "0.15.7" 44 | itertools = "0.14.0" 45 | derive_more = { version = "2.0", features = ["display"] } 46 | flate2 = "1.1" 47 | zip = { version = "3.0", default-features = false, features = ["deflate"] } 48 | tar = "0.4.44" 49 | dunce = "1.0" 50 | bytes = "1.10" 51 | leptos_hot_reload = "0.8.1" 52 | pathdiff = { version = "0.2.3", features = ["camino"] } 53 | semver = "1.0" 54 | md-5 = "0.10.6" 55 | base64ct = { version = "1.7.3", features = ["alloc"] } 56 | swc = "23.0" 57 | swc_common = "9.0" 58 | shlex = "1.3" 59 | cargo-generate = { version = "0.23.3", features = ["vendored-openssl"] } 60 | wasm-opt = "0.116.1" 61 | ignore = "0.4.23" 62 | walkdir = "2.5" 63 | 64 | [dev-dependencies] 65 | insta = { version = "1.43", features = ["yaml"] } 66 | temp-dir = "0.1.14" 67 | 68 | [features] 69 | full_tests = [] 70 | no_downloads = [] 71 | 72 | [profile.release] 73 | lto = true 74 | codegen-units = 1 75 | 76 | # The profile that 'cargo dist' will build with 77 | [profile.dist] 78 | inherits = "release" 79 | lto = "thin" 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 henrik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | - All Cargo.toml `[metadata.project.leptos]` entries should be changed from snake_case to kebab-case. 2 | - If using end-to-end testing, then the `end2end-dir` needs to be set as well. 3 | - `package_name` (and `PACKAGE_NAME`) has been renamed to `output_name` and `LEPTOS_OUTPUT_NAME`. 4 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # Target platforms to build apps for (Rust target-triple syntax) 11 | targets = [ 12 | "aarch64-apple-darwin", 13 | "aarch64-unknown-linux-gnu", 14 | "aarch64-unknown-linux-musl", 15 | "x86_64-apple-darwin", 16 | "x86_64-unknown-linux-gnu", 17 | "x86_64-unknown-linux-musl", 18 | "x86_64-pc-windows-msvc", 19 | ] 20 | # The installers to generate for each app 21 | installers = ["shell", "powershell"] 22 | # Which actions to run on pull requests 23 | pr-run-mode = "plan" 24 | # The archive format to use for windows builds (defaults .zip) 25 | windows-archive = ".tar.gz" 26 | # The archive format to use for non-windows builds (defaults .tar.xz) 27 | unix-archive = ".tar.gz" 28 | # Where to host releases 29 | hosting = "github" 30 | # Path that installers should place binaries in 31 | install-path = "CARGO_HOME" 32 | # Whether to install an updater program 33 | install-updater = false 34 | # Ignore out-of-date contents 35 | allow-dirty = ["ci"] 36 | 37 | [dist.github-custom-runners] 38 | x86_64-unknown-linux-gnu = "ubuntu-22.04" 39 | aarch64-unknown-linux-gnu = "ubuntu-22.04" 40 | aarch64-unknown-linux-musl = "ubuntu-22.04" 41 | 42 | [dist.github-custom-runners.x86_64-unknown-linux-musl] 43 | runner = "ubuntu-latest" 44 | container = "messense/rust-musl-cross:x86_64-musl" 45 | -------------------------------------------------------------------------------- /examples/project/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | [package] 4 | name = "example" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib", "rlib"] 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | 14 | [dependencies] 15 | leptos_router = { version = "0.7.7", default-features = false } 16 | leptos = { version = "0.7.7", default-features = false } 17 | leptos_meta = { version = "0.7.7", default-features = false } 18 | leptos_dom = { version = "0.7.7", default-features = false } 19 | 20 | 21 | gloo-net = { version = "0.6", features = ["http"] } 22 | log = "0.4.26" 23 | cfg-if = "1.0.0" 24 | 25 | # dependecies for client (enable hydrate set) 26 | wasm-bindgen = { version = "=0.2.100", optional = true } 27 | console_log = { version = "1.0", optional = true } 28 | console_error_panic_hook = { version = "0.1.7", optional = true } 29 | 30 | # dependecies for server (enable when ssr set) 31 | actix-files = { version = "0.6.6", optional = true } 32 | actix-web = { version = "4.9.0", features = ["macros"], optional = true } 33 | futures = { version = "0.3.31", optional = true } 34 | simple_logger = { version = "5.0.0", optional = true } 35 | serde_json = { version = "1.0.140", optional = true } 36 | reqwest = { version = "0.12.12", features = ["json"], optional = true } 37 | leptos_actix = { version = "0.7.7", optional = true } 38 | dotenvy = { version = "0.15.7", optional = true } 39 | 40 | [profile.release] 41 | codegen-units = 1 42 | lto = true 43 | opt-level = 'z' 44 | 45 | [features] 46 | default = ["ssr"] 47 | hydrate = [ 48 | "leptos/hydrate", 49 | "dep:wasm-bindgen", 50 | "dep:console_log", 51 | "dep:console_error_panic_hook", 52 | ] 53 | ssr = [ 54 | "leptos/ssr", 55 | "leptos_meta/ssr", 56 | "leptos_router/ssr", 57 | "dep:leptos_actix", 58 | "dep:reqwest", 59 | "dep:actix-web", 60 | "dep:actix-files", 61 | "dep:futures", 62 | "dep:simple_logger", 63 | "dep:serde_json", 64 | "dep:dotenvy", 65 | ] 66 | 67 | 68 | [package.metadata.leptos] 69 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 70 | output-name = "example" 71 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 72 | site-root = "target/site" 73 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 74 | # Defaults to pkg 75 | site-pkg-dir = "pkg" 76 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 77 | style-file = "style/main.scss" 78 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 79 | tailwind-input-file = "style/tailwind.css" 80 | # [Optional] Files in the asset-dir will be copied to the site-root directory 81 | assets-dir = "assets" 82 | # JS source dir. `wasm-bindgen` has the option to include JS snippets from JS files 83 | # with `#[wasm_bindgen(module = "/js/foo.js")]`. A change in any JS file in this dir 84 | # will trigger a rebuild. 85 | js-dir = "js" 86 | # [Optional] Additional files to watch 87 | watch-additional-files = ["additional_files"] 88 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 89 | site-addr = "127.0.0.1:3000" 90 | # The port to use for automatic reload monitoring 91 | reload-port = 3001 92 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 93 | end2end-cmd = "npx playwright test" 94 | end2end-dir = "end2end" 95 | # The browserlist query used for optimizing the CSS. 96 | browserquery = "defaults" 97 | # The features to use when compiling the bin target 98 | # 99 | # Optional. Can be over-ridden with the command line parameter --bin-features 100 | bin-features = ["ssr"] 101 | 102 | # If the --no-default-features flag should be used when compiling the bin target 103 | # 104 | # Optional. Defaults to false. 105 | bin-default-features = false 106 | 107 | # The features to use when compiling the lib target 108 | # 109 | # Optional. Can be over-ridden with the command line parameter --lib-features 110 | lib-features = ["hydrate"] 111 | 112 | # If the --no-default-features flag should be used when compiling the lib target 113 | # 114 | # Optional. Defaults to false. 115 | lib-default-features = false 116 | env = "dev" 117 | 118 | # Enables additional file hashes on outputted css, js, and wasm files 119 | # 120 | # Optional: Defaults to false. Can also be set with the LEPTOS_HASH_FILES=false env var 121 | hash-files = true 122 | 123 | server-fn-prefix = "/custom/prefix" 124 | disable-server-fn-hash = true 125 | server-fn-mod-path = true 126 | -------------------------------------------------------------------------------- /examples/project/additional_files/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Additional file" 3 | } 4 | -------------------------------------------------------------------------------- /examples/project/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leptos-rs/cargo-leptos/c115542e185b52c317c361d6c22403b9d5c2de59/examples/project/assets/favicon.ico -------------------------------------------------------------------------------- /examples/project/end2end/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "end2end", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "end2end", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@playwright/test": "^1.51.0", 13 | "@types/node": "^22.13.10" 14 | } 15 | }, 16 | "node_modules/@playwright/test": { 17 | "version": "1.51.0", 18 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.0.tgz", 19 | "integrity": "sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==", 20 | "dev": true, 21 | "license": "Apache-2.0", 22 | "dependencies": { 23 | "playwright": "1.51.0" 24 | }, 25 | "bin": { 26 | "playwright": "cli.js" 27 | }, 28 | "engines": { 29 | "node": ">=18" 30 | } 31 | }, 32 | "node_modules/@types/node": { 33 | "version": "22.13.10", 34 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", 35 | "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", 36 | "dev": true, 37 | "license": "MIT", 38 | "dependencies": { 39 | "undici-types": "~6.20.0" 40 | } 41 | }, 42 | "node_modules/fsevents": { 43 | "version": "2.3.2", 44 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 45 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 46 | "dev": true, 47 | "hasInstallScript": true, 48 | "license": "MIT", 49 | "optional": true, 50 | "os": [ 51 | "darwin" 52 | ], 53 | "engines": { 54 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 55 | } 56 | }, 57 | "node_modules/playwright": { 58 | "version": "1.51.0", 59 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", 60 | "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==", 61 | "dev": true, 62 | "license": "Apache-2.0", 63 | "dependencies": { 64 | "playwright-core": "1.51.0" 65 | }, 66 | "bin": { 67 | "playwright": "cli.js" 68 | }, 69 | "engines": { 70 | "node": ">=18" 71 | }, 72 | "optionalDependencies": { 73 | "fsevents": "2.3.2" 74 | } 75 | }, 76 | "node_modules/playwright-core": { 77 | "version": "1.51.0", 78 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz", 79 | "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==", 80 | "dev": true, 81 | "license": "Apache-2.0", 82 | "bin": { 83 | "playwright-core": "cli.js" 84 | }, 85 | "engines": { 86 | "node": ">=18" 87 | } 88 | }, 89 | "node_modules/undici-types": { 90 | "version": "6.20.0", 91 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 92 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 93 | "dev": true, 94 | "license": "MIT" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /examples/project/end2end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "end2end", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@playwright/test": "^1.51.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/project/end2end/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: "./tests", 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: "html", 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: "on-first-retry", 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: "chromium", 49 | use: { 50 | ...devices["Desktop Chrome"], 51 | }, 52 | }, 53 | 54 | { 55 | name: "firefox", 56 | use: { 57 | ...devices["Desktop Firefox"], 58 | }, 59 | }, 60 | 61 | { 62 | name: "webkit", 63 | use: { 64 | ...devices["Desktop Safari"], 65 | }, 66 | }, 67 | 68 | /* Test against mobile viewports. */ 69 | // { 70 | // name: 'Mobile Chrome', 71 | // use: { 72 | // ...devices['Pixel 5'], 73 | // }, 74 | // }, 75 | // { 76 | // name: 'Mobile Safari', 77 | // use: { 78 | // ...devices['iPhone 12'], 79 | // }, 80 | // }, 81 | 82 | /* Test against branded browsers. */ 83 | // { 84 | // name: 'Microsoft Edge', 85 | // use: { 86 | // channel: 'msedge', 87 | // }, 88 | // }, 89 | // { 90 | // name: 'Google Chrome', 91 | // use: { 92 | // channel: 'chrome', 93 | // }, 94 | // }, 95 | ], 96 | 97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 98 | // outputDir: 'test-results/', 99 | 100 | /* Run your local dev server before starting the tests */ 101 | // webServer: { 102 | // command: 'npm run start', 103 | // port: 3000, 104 | // }, 105 | }; 106 | 107 | export default config; 108 | -------------------------------------------------------------------------------- /examples/project/end2end/tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("homepage has title and links to intro page", async ({ page }) => { 4 | await page.goto("http://localhost:3000/"); 5 | 6 | await expect(page).toHaveTitle("Cargo Leptos"); 7 | 8 | await expect(page.locator("h1")).toHaveText("Hi from your Leptos WASM!"); 9 | }); 10 | -------------------------------------------------------------------------------- /examples/project/js/foo.js: -------------------------------------------------------------------------------- 1 | export function message() { 2 | return "JS"; 3 | } 4 | -------------------------------------------------------------------------------- /examples/project/src/app.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_meta::*; 3 | 4 | #[component] 5 | pub fn App() -> impl IntoView { 6 | provide_meta_context(); 7 | 8 | view! { 9 | 10 | 11 | <main class="my-0 mx-auto max-w-3xl text-center"> 12 | <h2 class="p-6 text-4xl">"Welcome to Leptos"</h2> 13 | <p class="px-10 pb-10 text-left">"This setup includes Tailwind and SASS"</p> 14 | </main> 15 | } 16 | } 17 | 18 | #[cfg(feature = "hydrate")] 19 | use wasm_bindgen::prelude::wasm_bindgen; 20 | #[cfg(feature = "hydrate")] 21 | #[wasm_bindgen(module = "/js/foo.js")] 22 | extern "C" { 23 | pub fn message() -> String; 24 | } 25 | 26 | #[cfg(not(feature = "hydrate"))] 27 | #[allow(dead_code)] 28 | pub fn message() -> String { 29 | "Rust".to_string() 30 | } 31 | -------------------------------------------------------------------------------- /examples/project/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | use cfg_if::cfg_if; 3 | 4 | cfg_if! { 5 | if #[cfg(feature = "hydrate")] { 6 | 7 | use wasm_bindgen::prelude::wasm_bindgen; 8 | use app::*; 9 | use leptos::*; 10 | 11 | #[wasm_bindgen] 12 | pub fn hydrate() { 13 | console_error_panic_hook::set_once(); 14 | _ = console_log::init_with_level(log::Level::Debug); 15 | 16 | logging::log!("hydrate mode - hydrating ({})", app::message()); 17 | 18 | leptos::mount::hydrate_body(|| { 19 | view! { <App/> } 20 | }); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/project/src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | #[cfg(feature = "ssr")] 3 | mod server; 4 | 5 | use cfg_if::cfg_if; 6 | 7 | cfg_if! { 8 | if #[cfg(feature = "ssr")] { 9 | #[actix_web::main] 10 | async fn main() -> std::io::Result<()> { 11 | server::run().await 12 | } 13 | } 14 | else { 15 | pub fn main() {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/project/src/server.rs: -------------------------------------------------------------------------------- 1 | use crate::app::*; 2 | use actix_files::Files; 3 | use actix_web::{App as ActixApp, *}; 4 | use leptos::{config::get_configuration, logging::warn, prelude::*}; 5 | use leptos_actix::{generate_route_list, LeptosRoutes}; 6 | use leptos_meta::MetaTags; 7 | 8 | pub async fn run() -> std::io::Result<()> { 9 | _ = dotenvy::dotenv(); 10 | 11 | let conf = get_configuration(None).unwrap(); 12 | let addr = conf.leptos_options.site_addr; 13 | 14 | warn!("serving at {addr}"); 15 | 16 | HttpServer::new(move || { 17 | // Generate the list of routes in your Leptos App 18 | let routes = generate_route_list(App); 19 | let leptos_options = &conf.leptos_options; 20 | let site_root = &leptos_options.site_root; 21 | 22 | ActixApp::new() 23 | .leptos_routes(routes, { 24 | let options = leptos_options.clone(); 25 | 26 | move || { 27 | view! { 28 | <!DOCTYPE html> 29 | <html lang="en"> 30 | <head> 31 | <meta charset="utf-8" /> 32 | <meta 33 | name="viewport" 34 | content="width=device-width, initial-scale=1" 35 | /> 36 | <AutoReload options=options.clone() /> 37 | <HydrationScripts options=options.clone() /> 38 | <MetaTags /> 39 | </head> 40 | <body> 41 | <App /> 42 | </body> 43 | </html> 44 | } 45 | } 46 | }) 47 | .service(Files::new("/", site_root.as_ref())) 48 | .wrap(middleware::Compress::default()) 49 | }) 50 | .bind(&addr)? 51 | .run() 52 | .await 53 | } 54 | -------------------------------------------------------------------------------- /examples/project/style/main.scss: -------------------------------------------------------------------------------- 1 | 2 | html { 3 | padding-top: 5rem; 4 | background-color: rgb(225, 225, 225); 5 | } 6 | @media (prefers-color-scheme: dark) { 7 | html { 8 | background-color: rgb(25, 25, 25); 9 | color: rgb(220, 220, 220); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/project/style/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /examples/workspace/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["project1/app", "project1/front", "project1/server", "project2"] 3 | resolver = "2" 4 | 5 | # need to be applied only to wasm build 6 | [profile.release] 7 | codegen-units = 1 8 | lto = true 9 | opt-level = 'z' 10 | 11 | [workspace.dependencies] 12 | leptos = { version = "0.6", default-features = false } 13 | leptos_meta = { version = "0.6", default-features = false } 14 | leptos_router = { version = "0.6", default-features = false } 15 | leptos_dom = { version = "0.6", default-features = false } 16 | leptos_actix = { version = "0.6" } 17 | 18 | gloo-net = { version = "0.6", features = ["http"] } 19 | 20 | # See https://github.com/leptos-rs/cargo-leptos for documentation of all the parameters. 21 | 22 | # A leptos project defines which workspace members 23 | # that are used together frontend (lib) & server (bin) 24 | [[workspace.metadata.leptos]] 25 | name = "project1" 26 | bin-package = "server-package" 27 | lib-package = "front-package" 28 | assets-dir = "project1/assets" 29 | style-file = "project1/css/main.scss" 30 | site-root = "target/site/project1" 31 | server-fn-prefix = "/custom/prefix" 32 | disable-server-fn-hash = true 33 | server-fn-mod-path = true 34 | -------------------------------------------------------------------------------- /examples/workspace/project1/app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app-package" 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 | leptos = { workspace = true, features = ["serde"] } 10 | leptos_meta.workspace = true 11 | 12 | [features] 13 | default = ["hydrate"] 14 | hydrate = ["leptos/hydrate", "leptos_meta/hydrate"] 15 | ssr = ["leptos/ssr", "leptos_meta/ssr"] 16 | -------------------------------------------------------------------------------- /examples/workspace/project1/app/src/lib.rs: -------------------------------------------------------------------------------- 1 | use leptos::*; 2 | use leptos_meta::*; 3 | 4 | #[component] 5 | pub fn App() -> impl IntoView { 6 | 7 | view! { 8 | <div> 9 | <Stylesheet id="leptos" href="/pkg/project1.css"/> 10 | <Title text="Cargo Leptos" /> 11 | <h1>"Hi from your Leptos WASM!"</h1> 12 | </div> 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/workspace/project1/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leptos-rs/cargo-leptos/c115542e185b52c317c361d6c22403b9d5c2de59/examples/workspace/project1/assets/favicon.ico -------------------------------------------------------------------------------- /examples/workspace/project1/css/main.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top: 6rem; 3 | width: 100%; 4 | text-align:center; 5 | font-size: xx-large; 6 | } 7 | 8 | html { 9 | background-color: rgb(225, 225, 225); 10 | } 11 | @media (prefers-color-scheme: dark) { 12 | html { 13 | background-color: rgb(25, 25, 25); 14 | color: rgb(220, 220, 220); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/workspace/project1/front/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "front-package" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | app-package = { path = "../app" } 13 | leptos = { workspace = true, features = ["serde", "hydrate"] } 14 | log = "0.4" 15 | wasm-bindgen = "=0.2.100" 16 | console_log = "0.2" 17 | console_error_panic_hook = "0.1" 18 | -------------------------------------------------------------------------------- /examples/workspace/project1/front/src/lib.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::wasm_bindgen; 2 | 3 | use app_package::*; 4 | use leptos::*; 5 | 6 | #[wasm_bindgen] 7 | pub fn hydrate() { 8 | console_error_panic_hook::set_once(); 9 | _ = console_log::init_with_level(log::Level::Debug); 10 | 11 | leptos::logging::log!("hydrate mode - hydrating"); 12 | 13 | leptos::mount_to_body(|| { 14 | view! { <App/> } 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /examples/workspace/project1/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server-package" 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 | app-package = { path = "../app", default-features = false, features = ["ssr"] } 10 | leptos = { workspace = true, features = ["serde", "ssr"] } 11 | leptos_meta = { workspace = true, features = ["ssr"] } 12 | leptos_router = { workspace = true, features = ["ssr"] } 13 | leptos_dom = { workspace = true, features = ["ssr"] } 14 | leptos_actix.workspace = true 15 | 16 | gloo-net = { version = "0.6", features = ["http"] } 17 | log = "0.4" 18 | cfg-if = "1.0" 19 | actix-web = "4" 20 | actix-files = "0.6" 21 | futures = "0.3" 22 | simple_logger = "5.0" 23 | serde_json = "1.0" 24 | reqwest = "0.12" 25 | dotenvy = "0.15" 26 | -------------------------------------------------------------------------------- /examples/workspace/project1/server/src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_web::*; 2 | use leptos::*; 3 | use leptos_actix::{generate_route_list, LeptosRoutes}; 4 | use tracing::*; 5 | 6 | fn app() -> impl IntoView { 7 | use app_package::*; 8 | 9 | view! { <App /> } 10 | } 11 | 12 | #[actix_web::main] 13 | pub async fn main() -> std::io::Result<()> { 14 | use actix_files::Files; 15 | 16 | _ = dotenvy::dotenv(); 17 | 18 | let conf = get_configuration(None).await.unwrap(); 19 | let addr = conf.leptos_options.site_addr; 20 | 21 | info!("serving at {addr}"); 22 | 23 | // Generate the list of routes in your Leptos App 24 | let routes = generate_route_list(app); 25 | 26 | HttpServer::new(move || { 27 | let leptos_options = &conf.leptos_options; 28 | 29 | let site_root = leptos_options.site_root.clone(); 30 | 31 | App::new() 32 | .leptos_routes(leptos_options.to_owned(), routes.to_owned(), app) 33 | .service(Files::new("/", site_root.to_owned())) 34 | .wrap(middleware::Compress::default()) 35 | }) 36 | .bind(addr)? 37 | .run() 38 | .await 39 | } 40 | -------------------------------------------------------------------------------- /examples/workspace/project2/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "project2" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | 12 | [dependencies] 13 | leptos = { workspace = true, features = ["serde"] } 14 | leptos_meta.workspace = true 15 | leptos_router.workspace = true 16 | 17 | 18 | gloo-net = { version = "0.6", features = ["http"] } 19 | log = "0.4" 20 | cfg-if = "1.0" 21 | 22 | # dependecies for client (enable hydrate set) 23 | wasm-bindgen = { version = "=0.2.100", optional = true } 24 | console_log = { version = "1.0", optional = true } 25 | console_error_panic_hook = { version = "0.1", optional = true } 26 | 27 | # dependecies for server (enable when ssr set) 28 | leptos_actix = { workspace = true, optional = true } 29 | actix-files = { version = "0.6", optional = true } 30 | actix-web = { version = "4", features = ["macros"], optional = true } 31 | futures = { version = "0.3", optional = true } 32 | simple_logger = { version = "5.0", optional = true } 33 | serde_json = { version = "1.0", optional = true } 34 | reqwest = { version = "0.12", features = ["json"], optional = true } 35 | dotenvy = { version = "0.15", optional = true } 36 | 37 | [features] 38 | default = ["ssr"] 39 | hydrate = [ 40 | "leptos/hydrate", 41 | "leptos_meta/hydrate", 42 | "leptos_router/hydrate", 43 | "dep:wasm-bindgen", 44 | "dep:console_log", 45 | "dep:console_error_panic_hook", 46 | ] 47 | ssr = [ 48 | "leptos/ssr", 49 | "leptos_meta/ssr", 50 | "leptos_router/ssr", 51 | "dep:leptos_actix", 52 | "dep:reqwest", 53 | "dep:actix-web", 54 | "dep:actix-files", 55 | "dep:futures", 56 | "dep:simple_logger", 57 | "dep:serde_json", 58 | "dep:dotenvy", 59 | ] 60 | 61 | 62 | [package.metadata.leptos] 63 | # See https://github.com/leptos-rs/cargo-leptos for documentation of all the parameters. 64 | 65 | # [Optional] Files in the asset_dir will be copied to the target/site directory 66 | assets-dir = "src/assets" 67 | 68 | # Main style file. If scss or sass then it will be compiled to css. 69 | # the parent folder will be watched for changes 70 | style-file = "src/main.scss" 71 | 72 | site-root = "target/site/project2" 73 | bin-features = ["ssr"] 74 | 75 | lib-features = ["hydrate"] 76 | -------------------------------------------------------------------------------- /examples/workspace/project2/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use leptos::*; 2 | use leptos_meta::*; 3 | 4 | #[component] 5 | pub fn App() -> impl IntoView { 6 | 7 | view! { 8 | <div> 9 | <Stylesheet id="leptos" href="/pkg/project2.css"/> 10 | <Title text="Cargo Leptos" /> 11 | <h1>"Hi from your Leptos WASM!"</h1> 12 | </div> 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/workspace/project2/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leptos-rs/cargo-leptos/c115542e185b52c317c361d6c22403b9d5c2de59/examples/workspace/project2/src/assets/favicon.ico -------------------------------------------------------------------------------- /examples/workspace/project2/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | use cfg_if::cfg_if; 3 | 4 | cfg_if! { 5 | if #[cfg(feature = "hydrate")] { 6 | 7 | use wasm_bindgen::prelude::wasm_bindgen; 8 | 9 | #[wasm_bindgen] 10 | pub fn hydrate() { 11 | use app::*; 12 | use leptos::*; 13 | 14 | console_error_panic_hook::set_once(); 15 | _ = console_log::init_with_level(log::Level::Debug); 16 | 17 | leptos::logging::log!("hydrate mode - hydrating"); 18 | 19 | leptos::mount_to_body(|| { 20 | view! { <App/> } 21 | }); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/workspace/project2/src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | #[cfg(all(feature = "ssr", not(feature = "hydrate")))] 3 | mod server; 4 | 5 | use cfg_if::cfg_if; 6 | 7 | cfg_if! { 8 | if #[cfg(all(feature = "ssr", not(feature = "hydrate")))] { 9 | #[actix_web::main] 10 | async fn main() -> std::io::Result<()> { 11 | server::run().await 12 | } 13 | } 14 | else { 15 | pub fn main() {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/workspace/project2/src/main.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top: 6rem; 3 | width: 100%; 4 | text-align:center; 5 | font-size: xx-large; 6 | } 7 | 8 | html { 9 | background-color: rgb(225, 225, 225); 10 | } 11 | @media (prefers-color-scheme: dark) { 12 | html { 13 | background-color: rgb(25, 25, 25); 14 | color: rgb(220, 220, 220); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/workspace/project2/src/server/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::app::*; 2 | use actix_files::Files; 3 | use actix_web::*; 4 | use leptos::*; 5 | use leptos_actix::{generate_route_list, LeptosRoutes}; 6 | use tracing::*; 7 | 8 | fn app() -> impl IntoView { 9 | view! { <App /> } 10 | } 11 | 12 | pub async fn run() -> std::io::Result<()> { 13 | _ = dotenvy::dotenv(); 14 | 15 | simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging"); 16 | 17 | let conf = get_configuration(None).await.unwrap(); 18 | let addr = conf.leptos_options.site_addr; 19 | 20 | info!("serving at {addr}"); 21 | 22 | // Generate the list of routes in your Leptos App 23 | let routes = generate_route_list(app); 24 | 25 | HttpServer::new(move || { 26 | let leptos_options = &conf.leptos_options; 27 | 28 | let pkg_dir = leptos_options.site_pkg_dir.clone(); 29 | let site_root = leptos_options.site_root.clone(); 30 | App::new() 31 | .leptos_routes(leptos_options.to_owned(), routes.to_owned(), app) 32 | .service(Files::new(&pkg_dir, format!("{site_root}/{pkg_dir}"))) 33 | .wrap(middleware::Compress::default()) 34 | }) 35 | .bind(addr)? 36 | .run() 37 | .await 38 | } 39 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "flake-utils_2": { 22 | "inputs": { 23 | "systems": "systems_2" 24 | }, 25 | "locked": { 26 | "lastModified": 1681202837, 27 | "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", 28 | "owner": "numtide", 29 | "repo": "flake-utils", 30 | "rev": "cfacdce06f30d2b68473a46042957675eebb3401", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "numtide", 35 | "repo": "flake-utils", 36 | "type": "github" 37 | } 38 | }, 39 | "nixpkgs": { 40 | "locked": { 41 | "lastModified": 1700390070, 42 | "narHash": "sha256-de9KYi8rSJpqvBfNwscWdalIJXPo8NjdIZcEJum1mH0=", 43 | "owner": "NixOS", 44 | "repo": "nixpkgs", 45 | "rev": "e4ad989506ec7d71f7302cc3067abd82730a4beb", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "NixOS", 50 | "ref": "nixos-unstable", 51 | "repo": "nixpkgs", 52 | "type": "github" 53 | } 54 | }, 55 | "nixpkgs_2": { 56 | "locked": { 57 | "lastModified": 1681358109, 58 | "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", 59 | "owner": "NixOS", 60 | "repo": "nixpkgs", 61 | "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", 62 | "type": "github" 63 | }, 64 | "original": { 65 | "owner": "NixOS", 66 | "ref": "nixpkgs-unstable", 67 | "repo": "nixpkgs", 68 | "type": "github" 69 | } 70 | }, 71 | "root": { 72 | "inputs": { 73 | "flake-utils": "flake-utils", 74 | "nixpkgs": "nixpkgs", 75 | "rust-overlay": "rust-overlay" 76 | } 77 | }, 78 | "rust-overlay": { 79 | "inputs": { 80 | "flake-utils": "flake-utils_2", 81 | "nixpkgs": "nixpkgs_2" 82 | }, 83 | "locked": { 84 | "lastModified": 1700446608, 85 | "narHash": "sha256-q/87GqBvQoUNBYiI3hwhsDqfyfk972RuZK+EwKab5s0=", 86 | "owner": "oxalica", 87 | "repo": "rust-overlay", 88 | "rev": "e17bfe3baa0487f0671c9ed0e9057d10987ba7f7", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "oxalica", 93 | "repo": "rust-overlay", 94 | "type": "github" 95 | } 96 | }, 97 | "systems": { 98 | "locked": { 99 | "lastModified": 1681028828, 100 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 101 | "owner": "nix-systems", 102 | "repo": "default", 103 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 104 | "type": "github" 105 | }, 106 | "original": { 107 | "owner": "nix-systems", 108 | "repo": "default", 109 | "type": "github" 110 | } 111 | }, 112 | "systems_2": { 113 | "locked": { 114 | "lastModified": 1681028828, 115 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 116 | "owner": "nix-systems", 117 | "repo": "default", 118 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 119 | "type": "github" 120 | }, 121 | "original": { 122 | "owner": "nix-systems", 123 | "repo": "default", 124 | "type": "github" 125 | } 126 | } 127 | }, 128 | "root": "root", 129 | "version": 7 130 | } 131 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | 2 | { 3 | description = "A basic Rust devshell"; 4 | 5 | inputs = { 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 | rust-overlay.url = "github:oxalica/rust-overlay"; 8 | flake-utils.url = "github:numtide/flake-utils"; 9 | }; 10 | 11 | outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: 12 | flake-utils.lib.eachDefaultSystem (system: 13 | let 14 | overlays = [ (import rust-overlay) ]; 15 | pkgs = import nixpkgs { 16 | inherit system overlays; 17 | }; 18 | in 19 | with pkgs; 20 | { 21 | devShells.default = mkShell { 22 | buildInputs = [ 23 | openssl 24 | pkg-config 25 | cargo-insta 26 | llvmPackages_latest.llvm 27 | llvmPackages_latest.bintools 28 | zlib.out 29 | llvmPackages_latest.lld 30 | (rust-bin.stable.latest.default.override { 31 | extensions= [ "rust-src" "rust-analyzer" ]; 32 | targets = [ "wasm32-unknown-unknown" ]; 33 | }) 34 | ]; 35 | 36 | shellHook = '' 37 | alias ls=exa 38 | alias find=fd 39 | alias grep=ripgrep 40 | ''; 41 | }; 42 | } 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /src/command/build.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::ext::compress; 4 | use crate::internal_prelude::*; 5 | use crate::{ 6 | compile, 7 | compile::ChangeSet, 8 | config::{Config, Project}, 9 | ext::fs, 10 | }; 11 | 12 | pub async fn build_all(conf: &Config) -> Result<()> { 13 | let mut first_failed_project = None; 14 | 15 | for proj in &conf.projects { 16 | debug!("Building project: {}, {}", proj.name, proj.working_dir); 17 | if !build_proj(proj).await? && first_failed_project.is_none() { 18 | first_failed_project = Some(proj); 19 | } 20 | } 21 | 22 | if let Some(proj) = first_failed_project { 23 | Err(eyre!("Failed to build {}", proj.name)) 24 | } else { 25 | Ok(()) 26 | } 27 | } 28 | 29 | /// Build the project. Returns true if the build was successful 30 | pub async fn build_proj(proj: &Arc<Project>) -> Result<bool> { 31 | if proj.site.root_dir.exists() { 32 | fs::rm_dir_content(&proj.site.root_dir).await.dot()?; 33 | } 34 | let changes = ChangeSet::all_changes(); 35 | 36 | let mut success = true; 37 | 38 | if !compile::front(proj, &changes).await.await??.is_success() { 39 | success = false; 40 | } 41 | if !compile::assets(proj, &changes).await.await??.is_success() { 42 | success = false; 43 | } 44 | if !compile::style(proj, &changes).await.await??.is_success() { 45 | success = false; 46 | } 47 | 48 | if !success { 49 | return Ok(false); 50 | } 51 | 52 | if proj.hash_files { 53 | compile::add_hashes_to_site(proj)?; 54 | } 55 | 56 | // it is important to do the precompression of the static files before building the 57 | // server to make it possible to include them as assets into the binary itself 58 | if proj.release && proj.precompress { 59 | compress::compress_static_files(proj.site.root_dir.clone().into()).await?; 60 | } 61 | 62 | if !compile::server(proj, &changes).await.await??.is_success() { 63 | return Ok(false); 64 | } 65 | 66 | Ok(true) 67 | } 68 | -------------------------------------------------------------------------------- /src/command/end2end.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use camino::Utf8Path; 4 | use tokio::process::Command; 5 | 6 | use crate::config::{Config, Project}; 7 | use crate::internal_prelude::*; 8 | use crate::service::serve; 9 | use crate::signal::Interrupt; 10 | 11 | pub async fn end2end_all(conf: &Config) -> Result<()> { 12 | for proj in &conf.projects { 13 | end2end_proj(proj).await?; 14 | } 15 | Ok(()) 16 | } 17 | 18 | pub async fn end2end_proj(proj: &Arc<Project>) -> Result<()> { 19 | if let Some(e2e) = &proj.end2end { 20 | if !super::build::build_proj(proj).await.dot()? { 21 | return Ok(()); 22 | } 23 | 24 | let server = serve::spawn(proj).await; 25 | try_run(&e2e.cmd, &e2e.dir) 26 | .await 27 | .wrap_err(format!("running: {}", &e2e.cmd))?; 28 | Interrupt::request_shutdown().await; 29 | server.await.dot()??; 30 | } else { 31 | info!("end2end the Crate.toml package.metadata.leptos.end2end_cmd parameter not set") 32 | } 33 | Ok(()) 34 | } 35 | 36 | async fn try_run(cmd: &str, dir: &Utf8Path) -> Result<()> { 37 | let mut parts = cmd.split(' '); 38 | let exe = parts 39 | .next() 40 | .ok_or_else(|| eyre!("Invalid command {cmd:?}"))?; 41 | 42 | let args = parts.collect::<Vec<_>>(); 43 | 44 | trace!("End2End running {cmd:?}"); 45 | let mut process = Command::new(which::which(exe)?) 46 | .args(args) 47 | .current_dir(dir) 48 | .spawn() 49 | .wrap_err(format!("Could not spawn command {cmd:?}"))?; 50 | 51 | let mut int = Interrupt::subscribe_any(); 52 | 53 | tokio::select! { 54 | _ = int.recv() => Ok(()), 55 | result = process.wait() => { 56 | let status = result?; 57 | if !status.success() { 58 | bail!("Command terminated with exit code {}", status) 59 | } 60 | Ok(()) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/command/mod.rs: -------------------------------------------------------------------------------- 1 | mod build; 2 | mod end2end; 3 | mod new; 4 | mod serve; 5 | mod test; 6 | pub mod watch; 7 | 8 | pub use build::build_all; 9 | pub use end2end::end2end_all; 10 | pub use new::NewCommand; 11 | pub use serve::serve; 12 | pub use test::test_all; 13 | pub use watch::watch; 14 | -------------------------------------------------------------------------------- /src/command/new.rs: -------------------------------------------------------------------------------- 1 | use cargo_generate::{generate, GenerateArgs, TemplatePath}; 2 | use clap::{ArgGroup, Args}; 3 | 4 | use crate::internal_prelude::*; 5 | 6 | // A subset of the cargo-generate commands available. 7 | // See: https://github.com/cargo-generate/cargo-generate/blob/main/src/args.rs 8 | 9 | #[derive(Clone, Debug, Args, PartialEq, Eq)] 10 | #[clap(arg_required_else_help(true))] 11 | #[clap(group(ArgGroup::new("template").args(&["git", "path"]).required(true).multiple(false)))] 12 | #[clap(about)] 13 | pub struct NewCommand { 14 | /// Git repository to clone template from. Can be a full URL (like 15 | /// `https://github.com/leptos-rs/start`), or a shortcut for one of our 16 | /// built-in templates: `leptos-rs/start`, `leptos-rs/start-axum`, 17 | /// `leptos-rs/start-axum-workspace`, or `leptos-rs/start-aws`. 18 | #[clap(short, long, group = "git-arg")] 19 | pub git: Option<String>, 20 | 21 | /// Branch to use when installing from git 22 | #[clap(short, long, conflicts_with = "tag", requires = "git-arg")] 23 | pub branch: Option<String>, 24 | 25 | /// Tag to use when installing from git 26 | #[clap(short, long, conflicts_with = "branch", requires = "git-arg")] 27 | pub tag: Option<String>, 28 | 29 | /// Local path to copy the template from. Can not be specified together with --git. 30 | #[clap(short, long)] 31 | pub path: Option<String>, 32 | 33 | /// Directory to create / project name; if the name isn't in kebab-case, it will be converted 34 | /// to kebab-case unless `--force` is given. 35 | #[clap(long, short, value_parser)] 36 | pub name: Option<String>, 37 | 38 | /// Don't convert the project name to kebab-case before creating the directory. 39 | /// Note that cargo generate won't overwrite an existing directory, even if `--force` is given. 40 | #[clap(long, short, action)] 41 | pub force: bool, 42 | 43 | /// Enables more verbose output. 44 | #[clap(long, short, action)] 45 | pub verbose: bool, 46 | 47 | /// Generate the template directly into the current dir. No subfolder will be created and no vcs is initialized. 48 | #[clap(long, action)] 49 | pub init: bool, 50 | } 51 | 52 | impl NewCommand { 53 | pub fn run(self) -> Result<()> { 54 | let Self { 55 | git, 56 | branch, 57 | tag, 58 | path, 59 | name, 60 | force, 61 | verbose, 62 | init, 63 | } = self; 64 | let args = GenerateArgs { 65 | template_path: TemplatePath { 66 | git: absolute_git_url(git), 67 | branch, 68 | tag, 69 | path, 70 | ..Default::default() 71 | }, 72 | name, 73 | force, 74 | verbose, 75 | init, 76 | ..Default::default() 77 | }; 78 | 79 | generate(args).dot_anyhow()?; 80 | 81 | Ok(()) 82 | } 83 | } 84 | 85 | /// Workaround to support short `new --git leptos-rs/start` command when behind Git proxy. 86 | /// See https://github.com/cargo-generate/cargo-generate/issues/752. 87 | fn absolute_git_url(url: Option<String>) -> Option<String> { 88 | url.map(|url| match url.as_str() { 89 | "start-trunk" | "leptos-rs/start-trunk" => format_leptos_starter_url("start-trunk"), 90 | "start-actix" | "leptos-rs/start" | "leptos-rs/start-actix" => { 91 | format_leptos_starter_url("start-actix") 92 | } 93 | "start-axum" | "leptos-rs/start-axum" => format_leptos_starter_url("start-axum"), 94 | "start-axum-workspace" | "leptos-rs/start-axum-workspace" => { 95 | format_leptos_starter_url("start-axum-workspace") 96 | } 97 | "start-aws" | "leptos-rs/start-aws" => format_leptos_starter_url("start-aws"), 98 | "start-spin" | "leptos-rs/start-spin" => format_leptos_starter_url("start-spin"), 99 | _ => url, 100 | }) 101 | } 102 | 103 | fn format_leptos_starter_url(repo: &str) -> String { 104 | format!("https://github.com/leptos-rs/{repo}") 105 | } 106 | -------------------------------------------------------------------------------- /src/command/serve.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::config::Project; 4 | use crate::internal_prelude::*; 5 | use crate::service::serve; 6 | 7 | pub async fn serve(proj: &Arc<Project>) -> Result<()> { 8 | if !super::build::build_proj(proj).await.dot()? { 9 | return Ok(()); 10 | } 11 | let server = serve::spawn_oneshot(proj).await; 12 | server.await??; 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /src/command/test.rs: -------------------------------------------------------------------------------- 1 | use crate::compile::{front_cargo_process, server_cargo_process}; 2 | use crate::config::{Config, Project}; 3 | use crate::ext::Paint; 4 | use crate::internal_prelude::*; 5 | use crate::logger::GRAY; 6 | 7 | pub async fn test_all(conf: &Config) -> Result<()> { 8 | let mut first_failed_project = None; 9 | 10 | for proj in &conf.projects { 11 | if !test_proj(proj).await? && first_failed_project.is_none() { 12 | first_failed_project = Some(proj); 13 | } 14 | } 15 | 16 | if let Some(proj) = first_failed_project { 17 | Err(eyre!("Tests failed for {}", proj.name)) 18 | } else { 19 | Ok(()) 20 | } 21 | } 22 | 23 | pub async fn test_proj(proj: &Project) -> Result<bool> { 24 | let (envs, line, mut proc) = server_cargo_process("test", proj).dot()?; 25 | 26 | let server_exit_status = proc.wait().await.dot()?; 27 | debug!("Cargo envs: {}", GRAY.paint(envs)); 28 | info!("Cargo server tests finished {}", GRAY.paint(line)); 29 | 30 | let (envs, line, mut proc) = front_cargo_process("test", false, proj).dot()?; 31 | 32 | let front_exit_status = proc.wait().await.dot()?; 33 | debug!("Cargo envs: {}", GRAY.paint(envs)); 34 | info!("Cargo front tests finished {}", GRAY.paint(line)); 35 | 36 | Ok(server_exit_status.success() && front_exit_status.success()) 37 | } 38 | -------------------------------------------------------------------------------- /src/command/watch.rs: -------------------------------------------------------------------------------- 1 | use super::build::build_proj; 2 | use crate::internal_prelude::*; 3 | use crate::{ 4 | compile::{self}, 5 | config::Project, 6 | service, 7 | signal::{Interrupt, Outcome, Product, ProductSet, ReloadSignal, ServerRestart}, 8 | }; 9 | use leptos_hot_reload::ViewMacros; 10 | use std::sync::Arc; 11 | use tokio::sync::broadcast::error::RecvError; 12 | use tokio::try_join; 13 | 14 | pub async fn watch(proj: &Arc<Project>) -> Result<()> { 15 | // even if the build fails, we continue 16 | build_proj(proj).await?; 17 | 18 | // but if ctrl-c is pressed, we stop 19 | if Interrupt::is_shutdown_requested().await { 20 | return Ok(()); 21 | } 22 | 23 | if proj.hot_reload && proj.release { 24 | log::warn!("warning: Hot reloading does not currently work in --release mode."); 25 | } 26 | 27 | let view_macros = if proj.hot_reload { 28 | // build initial set of view macros for patching 29 | let view_macros = ViewMacros::new(); 30 | view_macros 31 | .update_from_paths(&proj.lib.src_paths) 32 | .wrap_anyhow_err("Couldn't update view-macro watch")?; 33 | Some(view_macros) 34 | } else { 35 | None 36 | }; 37 | 38 | service::notify::spawn(proj, view_macros).await?; 39 | service::serve::spawn(proj).await; 40 | service::reload::spawn(proj).await; 41 | 42 | let res = run_loop(proj).await; 43 | if res.is_err() { 44 | Interrupt::request_shutdown().await; 45 | } 46 | res 47 | } 48 | 49 | pub async fn run_loop(proj: &Arc<Project>) -> Result<()> { 50 | let mut int = Interrupt::subscribe_any(); 51 | loop { 52 | debug!("Watch waiting for changes"); 53 | 54 | let int = int.recv().await; 55 | // Do not terminate the execution of watch if the receiver lagged behind as it might be a slow receiver 56 | // It happens when many files are modified in short period and it exceeds the channel capacity. 57 | if matches!(int, Err(RecvError::Closed)) { 58 | return Err(RecvError::Closed).dot(); 59 | } 60 | 61 | if Interrupt::is_shutdown_requested().await { 62 | debug!("Shutting down"); 63 | return Ok(()); 64 | } 65 | 66 | runner(proj).await?; 67 | } 68 | } 69 | 70 | pub async fn runner(proj: &Arc<Project>) -> Result<()> { 71 | let changes = Interrupt::get_source_changes().await; 72 | 73 | let server_hdl = compile::server(proj, &changes).await; 74 | let front_hdl = compile::front(proj, &changes).await; 75 | let assets_hdl = compile::assets(proj, &changes).await; 76 | let style_hdl = compile::style(proj, &changes).await; 77 | 78 | let (server, front, assets, style) = try_join!(server_hdl, front_hdl, assets_hdl, style_hdl)?; 79 | 80 | let outcomes = vec![server?, front?, assets?, style?]; 81 | 82 | let interrupted = outcomes.iter().any(|outcome| *outcome == Outcome::Stopped); 83 | if interrupted { 84 | info!("Build interrupted. Restarting."); 85 | return Ok(()); 86 | } 87 | 88 | let failed = outcomes.iter().any(|outcome| *outcome == Outcome::Failed); 89 | if failed { 90 | warn!("Build failed"); 91 | Interrupt::clear_source_changes().await; 92 | return Ok(()); 93 | } 94 | 95 | let set = ProductSet::from(outcomes); 96 | 97 | if set.is_empty() { 98 | trace!("Build step done with no changes"); 99 | } else { 100 | trace!("Build step done with changes: {set}"); 101 | } 102 | 103 | if set.contains(&Product::Server) { 104 | // send product change, then the server will send the reload once it has restarted 105 | ServerRestart::send(); 106 | info!("Watch updated {set}. Server restarting") 107 | } else if set.only_style() { 108 | ReloadSignal::send_style(); 109 | info!("Watch updated style") 110 | } else if set.contains_any(&[Product::Front, Product::Assets]) { 111 | ReloadSignal::send_full(); 112 | info!("Watch updated {set}") 113 | } 114 | Interrupt::clear_source_changes().await; 115 | Ok(()) 116 | } 117 | -------------------------------------------------------------------------------- /src/compile/assets.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use super::ChangeSet; 4 | use crate::config::Project; 5 | use crate::ext::Paint; 6 | use crate::internal_prelude::*; 7 | use crate::signal::{Outcome, Product}; 8 | use crate::{ext::PathExt, fs, logger::GRAY}; 9 | use camino::{Utf8Path, Utf8PathBuf}; 10 | use tokio::task::JoinHandle; 11 | 12 | pub async fn assets( 13 | proj: &Arc<Project>, 14 | changes: &ChangeSet, 15 | ) -> JoinHandle<Result<Outcome<Product>>> { 16 | let changes = changes.clone(); 17 | 18 | let proj = proj.clone(); 19 | tokio::spawn(async move { 20 | if !changes.need_assets_change() { 21 | return Ok(Outcome::Success(Product::None)); 22 | } 23 | let Some(assets) = &proj.assets else { 24 | return Ok(Outcome::Success(Product::None)); 25 | }; 26 | let dest_root = &proj.site.root_dir; 27 | let pkg_dir = &proj.site.pkg_dir; 28 | 29 | // if reserved.contains(assets.dir) { 30 | // warn!("Assets reserved filename for Leptos. Please remove {watched:?}"); 31 | // return Ok(false); 32 | // } 33 | trace!("Assets starting resync"); 34 | resync(&assets.dir, dest_root, pkg_dir).await?; 35 | debug!("Assets finished"); 36 | Ok(Outcome::Success(Product::Assets)) 37 | }) 38 | } 39 | 40 | pub fn reserved(src: &Utf8Path, pkg_dir: &Utf8Path) -> Vec<Utf8PathBuf> { 41 | vec![src.join("index.html"), pkg_dir.to_path_buf()] 42 | } 43 | 44 | // pub async fn update(config: &Config) -> Result<()> { 45 | // if let Some(src) = &config.leptos.assets_dir { 46 | // let dest = DEST.to_canoncial_dir().dot()?; 47 | // let src = src.to_canoncial_dir().dot()?; 48 | 49 | // resync(&src, &dest) 50 | // .await 51 | // .context(format!("Could not synchronize {src:?} with {dest:?}"))?; 52 | // } 53 | // Ok(()) 54 | // } 55 | 56 | async fn resync(src: &Utf8Path, dest: &Utf8Path, pkg_dir: &Utf8Path) -> Result<()> { 57 | clean_dest(dest, pkg_dir) 58 | .await 59 | .wrap_err(format!("Cleaning {dest:?}"))?; 60 | let reserved = reserved(src, pkg_dir); 61 | mirror(src, dest, &reserved) 62 | .await 63 | .wrap_err(format!("Mirroring {src:?} -> {dest:?}")) 64 | } 65 | 66 | async fn clean_dest(dest: &Utf8Path, pkg_dir: &Utf8Path) -> Result<()> { 67 | let pkg_dir_name = match pkg_dir.file_name() { 68 | Some(name) => name, 69 | None => { 70 | warn!("Assets No site-pkg-dir given, defaulting to 'pkg' for checks what to delete."); 71 | warn!("Assets This will probably delete already generated files."); 72 | "pkg" 73 | } 74 | }; 75 | 76 | let mut entries = fs::read_dir(dest).await?; 77 | while let Some(entry) = entries.next_entry().await? { 78 | let path = entry.path(); 79 | 80 | if entry.file_type().await?.is_dir() { 81 | if entry.file_name() != pkg_dir_name { 82 | debug!( 83 | "Assets removing folder {}", 84 | GRAY.paint(path.to_string_lossy()) 85 | ); 86 | fs::remove_dir_all(path).await?; 87 | } 88 | } else if entry.file_name() != "index.html" { 89 | debug!( 90 | "Assets removing file {}", 91 | GRAY.paint(path.to_string_lossy()) 92 | ); 93 | fs::remove_file(path).await?; 94 | } 95 | } 96 | Ok(()) 97 | } 98 | 99 | async fn mirror(src_root: &Utf8Path, dest_root: &Utf8Path, reserved: &[Utf8PathBuf]) -> Result<()> { 100 | let mut entries = src_root.read_dir_utf8()?; 101 | while let Some(Ok(entry)) = entries.next() { 102 | let from = entry.path().to_path_buf(); 103 | let to = from.rebase(src_root, dest_root)?; 104 | if reserved.contains(&from) { 105 | warn!(""); 106 | continue; 107 | } 108 | 109 | if entry.file_type()?.is_dir() { 110 | debug!( 111 | "Assets copy folder {} -> {}", 112 | GRAY.paint(from.as_str()), 113 | GRAY.paint(to.as_str()) 114 | ); 115 | fs::copy_dir_all(from, to).await?; 116 | } else { 117 | debug!( 118 | "Assets copy file {} -> {}", 119 | GRAY.paint(from.as_str()), 120 | GRAY.paint(to.as_str()) 121 | ); 122 | fs::copy(from, to).await?; 123 | } 124 | } 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/compile/change.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::Deref, vec}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq)] 4 | pub enum Change { 5 | /// sent when a bin target source file is changed 6 | BinSource, 7 | /// sent when a lib target source file is changed 8 | LibSource, 9 | /// sent when an asset file changed 10 | Asset, 11 | /// sent when a style file changed 12 | Style, 13 | /// Cargo.toml changed 14 | Conf, 15 | /// Additional file changed 16 | Additional, 17 | } 18 | 19 | #[derive(Debug, Default, Clone)] 20 | pub struct ChangeSet(Vec<Change>); 21 | 22 | impl ChangeSet { 23 | pub fn new() -> Self { 24 | Self(Vec::new()) 25 | } 26 | 27 | pub fn all_changes() -> Self { 28 | Self(vec![ 29 | Change::BinSource, 30 | Change::LibSource, 31 | Change::Style, 32 | Change::Conf, 33 | Change::Asset, 34 | ]) 35 | } 36 | 37 | pub fn is_empty(&self) -> bool { 38 | self.0.is_empty() 39 | } 40 | 41 | pub fn clear(&mut self) { 42 | self.0.clear() 43 | } 44 | 45 | pub fn need_server_build(&self) -> bool { 46 | self.0.contains(&Change::BinSource) 47 | || self.0.contains(&Change::Conf) 48 | || self.0.contains(&Change::Additional) 49 | } 50 | 51 | pub fn need_front_build(&self) -> bool { 52 | self.0.contains(&Change::LibSource) 53 | || self.0.contains(&Change::Conf) 54 | || self.0.contains(&Change::Additional) 55 | } 56 | 57 | pub fn need_style_build(&self, css_files: bool, css_in_source: bool) -> bool { 58 | (css_files && self.0.contains(&Change::Style)) 59 | || (css_in_source && self.0.contains(&Change::LibSource)) 60 | } 61 | 62 | pub fn need_assets_change(&self) -> bool { 63 | self.0.contains(&Change::Asset) 64 | } 65 | 66 | pub fn add(&mut self, change: Change) -> bool { 67 | if !self.0.contains(&change) { 68 | self.0.push(change); 69 | true 70 | } else { 71 | false 72 | } 73 | } 74 | } 75 | 76 | impl Deref for ChangeSet { 77 | type Target = Vec<Change>; 78 | 79 | fn deref(&self) -> &Self::Target { 80 | &self.0 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/compile/hash.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Project, ext::eyre::CustomWrapErr, internal_prelude::*}; 2 | use base64ct::{Base64UrlUnpadded, Encoding}; 3 | use camino::Utf8PathBuf; 4 | use eyre::{ContextCompat, Result}; 5 | use md5::{Digest, Md5}; 6 | use std::{collections::HashMap, fs}; 7 | 8 | ///Adds hashes to the filenames of the css, js, and wasm files in the output 9 | pub fn add_hashes_to_site(proj: &Project) -> Result<()> { 10 | let files_to_hashes = compute_front_file_hashes(proj).dot()?; 11 | 12 | debug!("Hash computed: {files_to_hashes:?}"); 13 | 14 | let renamed_files = rename_files(&files_to_hashes).dot()?; 15 | 16 | replace_in_file( 17 | &renamed_files[&proj.lib.js_file.dest], 18 | &renamed_files, 19 | &proj.site.root_relative_pkg_dir(), 20 | ); 21 | 22 | fs::create_dir_all( 23 | proj.hash_file 24 | .abs 25 | .parent() 26 | .wrap_err_with(|| format!("no parent dir for {}", proj.hash_file.abs))?, 27 | ) 28 | .wrap_err_with(|| format!("Failed to create parent dir for {}", proj.hash_file.abs))?; 29 | 30 | fs::write( 31 | &proj.hash_file.abs, 32 | format!( 33 | "{}: {}\n{}: {}\n{}: {}\n", 34 | proj.lib 35 | .js_file 36 | .dest 37 | .extension() 38 | .ok_or(eyre!("no extension"))?, 39 | files_to_hashes[&proj.lib.js_file.dest], 40 | proj.lib 41 | .wasm_file 42 | .dest 43 | .extension() 44 | .ok_or(eyre!("no extension"))?, 45 | files_to_hashes[&proj.lib.wasm_file.dest], 46 | proj.style 47 | .site_file 48 | .dest 49 | .extension() 50 | .ok_or(eyre!("no extension"))?, 51 | files_to_hashes[&proj.style.site_file.dest] 52 | ), 53 | ) 54 | .wrap_err_with(|| format!("Failed to write hash file to {}", proj.hash_file.abs))?; 55 | 56 | debug!("Hash written to {}", proj.hash_file.abs); 57 | 58 | Ok(()) 59 | } 60 | 61 | fn compute_front_file_hashes(proj: &Project) -> Result<HashMap<Utf8PathBuf, String>> { 62 | let mut files_to_hashes = HashMap::new(); 63 | 64 | let mut stack = vec![proj.site.root_relative_pkg_dir().into_std_path_buf()]; 65 | 66 | while let Some(path) = stack.pop() { 67 | if let Ok(entries) = fs::read_dir(path) { 68 | for entry in entries.flatten() { 69 | let path = entry.path(); 70 | 71 | if path.is_file() { 72 | if let Some(extension) = path.extension() { 73 | if extension == "css" && path != proj.style.site_file.dest { 74 | continue; 75 | } 76 | } 77 | 78 | // Check if the path contains snippets and also if it 79 | // contains inline{}.js. We do not want to hash these files 80 | // as the webassembly will look for an unhashed version of 81 | // the .js file. The folder though can be hashed. 82 | if let Some(path_str) = path.to_str() { 83 | if path_str.contains("snippets") { 84 | if let Some(file_name) = path.file_name() { 85 | let file_name_str = file_name.to_string_lossy(); 86 | if file_name_str.contains("inline") { 87 | if let Some(extension) = path.extension() { 88 | if extension == "js" { 89 | continue; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | let hash = Base64UrlUnpadded::encode_string( 98 | &Md5::new().chain_update(fs::read(&path)?).finalize(), 99 | ); 100 | 101 | files_to_hashes.insert( 102 | Utf8PathBuf::from_path_buf(path).expect("invalid path"), 103 | hash, 104 | ); 105 | } else if path.is_dir() { 106 | stack.push(path); 107 | } 108 | } 109 | } 110 | } 111 | 112 | Ok(files_to_hashes) 113 | } 114 | 115 | fn rename_files( 116 | files_to_hashes: &HashMap<Utf8PathBuf, String>, 117 | ) -> Result<HashMap<Utf8PathBuf, Utf8PathBuf>> { 118 | let mut old_to_new_paths = HashMap::new(); 119 | 120 | for (path, hash) in files_to_hashes { 121 | let mut new_path = path.clone(); 122 | 123 | new_path.set_file_name(format!( 124 | "{}.{}.{}", 125 | path.file_stem().ok_or(eyre!("no file stem"))?, 126 | hash, 127 | path.extension().ok_or(eyre!("no extension"))?, 128 | )); 129 | 130 | fs::rename(path, &new_path) 131 | .wrap_err_with(|| format!("Failed to rename {path} to {new_path}"))?; 132 | 133 | old_to_new_paths.insert(path.clone(), new_path); 134 | } 135 | 136 | Ok(old_to_new_paths) 137 | } 138 | 139 | fn replace_in_file( 140 | path: &Utf8PathBuf, 141 | old_to_new_paths: &HashMap<Utf8PathBuf, Utf8PathBuf>, 142 | root_dir: &Utf8PathBuf, 143 | ) { 144 | let mut contents = fs::read_to_string(path) 145 | .unwrap_or_else(|e| panic!("error {e}: could not read file {}", path)); 146 | 147 | for (old_path, new_path) in old_to_new_paths { 148 | let old_path = old_path 149 | .strip_prefix(root_dir) 150 | .expect("could not strip root path"); 151 | let new_path = new_path 152 | .strip_prefix(root_dir) 153 | .expect("could not strip root path"); 154 | 155 | contents = contents.replace(old_path.as_str(), new_path.as_str()); 156 | } 157 | 158 | fs::write(path, contents).expect("could not write file"); 159 | } 160 | -------------------------------------------------------------------------------- /src/compile/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests; 3 | 4 | mod assets; 5 | mod change; 6 | mod front; 7 | mod hash; 8 | mod sass; 9 | mod server; 10 | mod style; 11 | mod tailwind; 12 | 13 | pub use assets::assets; 14 | pub use change::{Change, ChangeSet}; 15 | pub use front::{front, front_cargo_process}; 16 | pub use hash::add_hashes_to_site; 17 | pub use server::{server, server_cargo_process}; 18 | pub use style::style; 19 | 20 | use itertools::Itertools; 21 | 22 | fn build_cargo_command_string(args: impl IntoIterator<Item = String>) -> String { 23 | std::iter::once("cargo".to_owned()) 24 | .chain(args.into_iter().map(|arg| { 25 | if arg.contains(' ') { 26 | format!("'{arg}'") 27 | } else { 28 | arg 29 | } 30 | })) 31 | .join(" ") 32 | } 33 | -------------------------------------------------------------------------------- /src/compile/sass.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use crate::{ 3 | ext::{ 4 | sync::{wait_piped_interruptible, CommandResult, OutputExt}, 5 | Paint, 6 | }, 7 | logger::GRAY, 8 | signal::{Interrupt, Outcome}, 9 | }; 10 | use tokio::process::Command; 11 | 12 | use crate::{ext::Exe, service::site::SourcedSiteFile}; 13 | 14 | pub async fn compile_sass(style_file: &SourcedSiteFile, optimise: bool) -> Result<Outcome<String>> { 15 | let mut args = vec![style_file.source.as_str()]; 16 | optimise.then(|| args.push("--no-source-map")); 17 | 18 | let exe = Exe::Sass.get().await.dot()?; 19 | 20 | let mut cmd = Command::new(exe); 21 | cmd.args(&args); 22 | 23 | trace!( 24 | "Style running {}", 25 | GRAY.paint(format!("sass {}", args.join(" "))) 26 | ); 27 | 28 | match wait_piped_interruptible("Dart Sass", cmd, Interrupt::subscribe_any()).await? { 29 | CommandResult::Success(output) => Ok(Outcome::Success(output.stdout())), 30 | CommandResult::Interrupted => Ok(Outcome::Stopped), 31 | CommandResult::Failure(output) => { 32 | warn!("Dart Sass failed with:"); 33 | println!("{}", output.stderr()); 34 | Ok(Outcome::Failed) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/compile/server.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use super::ChangeSet; 4 | use crate::{ 5 | config::Project, 6 | ext::sync::{wait_interruptible, CommandResult}, 7 | internal_prelude::*, 8 | logger::GRAY, 9 | signal::{Interrupt, Outcome, Product}, 10 | }; 11 | use shlex::Shlex; 12 | use tokio::{ 13 | process::{Child, Command}, 14 | task::JoinHandle, 15 | }; 16 | 17 | pub async fn server( 18 | proj: &Arc<Project>, 19 | changes: &ChangeSet, 20 | ) -> JoinHandle<Result<Outcome<Product>>> { 21 | let proj = proj.clone(); 22 | let changes = changes.clone(); 23 | 24 | tokio::spawn(async move { 25 | if !changes.need_server_build() { 26 | return Ok(Outcome::Success(Product::None)); 27 | } 28 | 29 | let (envs, line, process) = server_cargo_process("build", &proj)?; 30 | debug!("CARGO SERVER COMMAND: {:?}", process); 31 | match wait_interruptible("Cargo", process, Interrupt::subscribe_any()).await? { 32 | CommandResult::Success(_) => { 33 | debug!("Cargo envs: {}", GRAY.paint(envs)); 34 | info!("Cargo finished {}", GRAY.paint(line)); 35 | 36 | let changed = proj 37 | .site 38 | .did_external_file_change(&proj.bin.exe_file) 39 | .await 40 | .dot()?; 41 | if changed { 42 | debug!("Cargo server bin changed"); 43 | Ok(Outcome::Success(Product::Server)) 44 | } else { 45 | debug!("Cargo server bin unchanged"); 46 | Ok(Outcome::Success(Product::None)) 47 | } 48 | } 49 | CommandResult::Interrupted => Ok(Outcome::Stopped), 50 | CommandResult::Failure(_) => Ok(Outcome::Failed), 51 | } 52 | }) 53 | } 54 | 55 | pub fn server_cargo_process(cmd: &str, proj: &Project) -> Result<(String, String, Child)> { 56 | let raw_command = proj.bin.cargo_command.as_deref().unwrap_or("cargo"); 57 | let mut command_iter = Shlex::new(raw_command); 58 | 59 | if command_iter.had_error { 60 | panic!("bin-cargo-command cannot contain escaped quotes. Not sure why you'd want to") 61 | } 62 | 63 | let cargo_command = command_iter 64 | .next() 65 | .expect("Failed to get bin command. This should default to cargo"); 66 | let mut command: Command = Command::new(cargo_command); 67 | 68 | let args: Vec<String> = command_iter.collect(); 69 | command.args(args); 70 | 71 | let (envs, line) = build_cargo_server_cmd(cmd, proj, &mut command); 72 | Ok((envs, line, command.spawn()?)) 73 | } 74 | 75 | pub fn build_cargo_server_cmd( 76 | cmd: &str, 77 | proj: &Project, 78 | command: &mut Command, 79 | ) -> (String, String) { 80 | let mut args = vec![ 81 | cmd.to_string(), 82 | format!("--package={}", proj.bin.name.as_str()), 83 | ]; 84 | 85 | // If we're building the bin target for wasm, we want it to be a lib so it 86 | // can be run by wasmtime or spin or wasmer or whatever 87 | let server_is_wasm = match &proj.bin.target_triple { 88 | Some(t) => t.contains("wasm"), 89 | None => false, 90 | }; 91 | if cmd != "test" && !server_is_wasm { 92 | args.push(format!("--bin={}", proj.bin.target)) 93 | } else if cmd != "test" && server_is_wasm { 94 | args.push("--lib".to_string()) 95 | } 96 | 97 | if let Some(target_dir) = &proj.bin.target_dir { 98 | args.push(format!("--target-dir={target_dir}")); 99 | } 100 | if let Some(triple) = &proj.bin.target_triple { 101 | args.push(format!("--target={triple}")); 102 | } 103 | 104 | if !proj.bin.default_features { 105 | args.push("--no-default-features".to_string()); 106 | } 107 | 108 | if !proj.bin.features.is_empty() { 109 | args.push(format!("--features={}", proj.bin.features.join(","))); 110 | } 111 | 112 | debug!("BIN CARGO ARGS: {:?}", &proj.bin.cargo_args); 113 | // Add cargo flags to cargo command 114 | if let Some(cargo_args) = &proj.bin.cargo_args { 115 | args.extend_from_slice(cargo_args); 116 | } 117 | proj.bin.profile.add_to_args(&mut args); 118 | 119 | let envs = proj.to_envs(false); 120 | 121 | let envs_str = envs 122 | .iter() 123 | .map(|(name, val)| format!("{name}={val}")) 124 | .collect::<Vec<_>>() 125 | .join(" "); 126 | 127 | command.args(&args).envs(envs); 128 | let line = super::build_cargo_command_string(args); 129 | (envs_str, line) 130 | } 131 | -------------------------------------------------------------------------------- /src/compile/style.rs: -------------------------------------------------------------------------------- 1 | use super::ChangeSet; 2 | use crate::internal_prelude::*; 3 | use crate::{ 4 | compile::{sass::compile_sass, tailwind::compile_tailwind}, 5 | config::Project, 6 | ext::{Paint, PathBufExt}, 7 | fs, 8 | logger::GRAY, 9 | signal::{Outcome, Product}, 10 | }; 11 | use lightningcss::{ 12 | stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet}, 13 | targets::Browsers, 14 | targets::Targets, 15 | }; 16 | use std::sync::Arc; 17 | use tokio::task::JoinHandle; 18 | 19 | pub async fn style( 20 | proj: &Arc<Project>, 21 | changes: &ChangeSet, 22 | ) -> JoinHandle<Result<Outcome<Product>>> { 23 | let changes = changes.clone(); 24 | let proj = proj.clone(); 25 | 26 | tokio::spawn(async move { 27 | let css_in_source = proj.style.tailwind.is_some(); 28 | if !changes.need_style_build(true, css_in_source) { 29 | debug!("Style no build needed {changes:?}"); 30 | return Ok(Outcome::Success(Product::None)); 31 | } 32 | build(&proj).await 33 | }) 34 | } 35 | 36 | fn build_sass(proj: &Arc<Project>) -> JoinHandle<Result<Outcome<String>>> { 37 | let proj = proj.clone(); 38 | tokio::spawn(async move { 39 | let Some(style_file) = &proj.style.file else { 40 | trace!("Style not configured"); 41 | return Ok(Outcome::Success("".to_string())); 42 | }; 43 | 44 | debug!("Style found: {}", &style_file); 45 | fs::create_dir_all(style_file.dest.clone().without_last()) 46 | .await 47 | .dot()?; 48 | match style_file.source.extension() { 49 | Some("sass") | Some("scss") => compile_sass(style_file, proj.release) 50 | .await 51 | .wrap_err(format!("compile sass/scss: {}", &style_file)), 52 | Some("css") => Ok(Outcome::Success( 53 | fs::read_to_string(&style_file.source).await.dot()?, 54 | )), 55 | _ => bail!("Not a css/sass/scss style file: {}", &style_file), 56 | } 57 | }) 58 | } 59 | 60 | fn build_tailwind(proj: &Arc<Project>) -> JoinHandle<Result<Outcome<String>>> { 61 | let proj = proj.clone(); 62 | tokio::spawn(async move { 63 | let Some(tw_conf) = proj.style.tailwind.as_ref() else { 64 | trace!("Tailwind not configured"); 65 | return Ok(Outcome::Success("".to_string())); 66 | }; 67 | trace!("Tailwind config: {:?}", &tw_conf); 68 | compile_tailwind(&proj, tw_conf).await 69 | }) 70 | } 71 | 72 | async fn build(proj: &Arc<Project>) -> Result<Outcome<Product>> { 73 | let css_handle = build_sass(proj); 74 | let tw_handle = build_tailwind(proj); 75 | let css = css_handle.await??; 76 | let tw = tw_handle.await??; 77 | 78 | use Outcome::*; 79 | let css = match (css, tw) { 80 | (Stopped, _) | (_, Stopped) => return Ok(Stopped), 81 | (Failed, _) | (_, Failed) => return Ok(Failed), 82 | (Success(css), Success(tw)) => format!("{css}\n{tw}"), 83 | }; 84 | Ok(Success(process_css(proj, css).await?)) 85 | } 86 | 87 | fn browser_lists(query: &str) -> Result<Option<Browsers>> { 88 | Browsers::from_browserslist([query]).wrap_err(format!("Error in browserlist query: {query}")) 89 | } 90 | 91 | async fn process_css(proj: &Project, css: String) -> Result<Product> { 92 | let browsers = browser_lists(&proj.style.browserquery).wrap_err("leptos.style.browserquery")?; 93 | let targets = Targets::from(browsers); 94 | 95 | let mut stylesheet = 96 | StyleSheet::parse(&css, ParserOptions::default()).map_err(|e| eyre!("{e}"))?; 97 | 98 | if proj.release { 99 | let minify_options = MinifyOptions { 100 | targets, 101 | ..Default::default() 102 | }; 103 | stylesheet.minify(minify_options)?; 104 | } 105 | 106 | let options = PrinterOptions::<'_> { 107 | targets, 108 | minify: proj.release, 109 | ..Default::default() 110 | }; 111 | 112 | let style_output = stylesheet.to_css(options)?; 113 | 114 | let bytes = style_output.code.as_bytes(); 115 | 116 | let prod = match proj.site.updated_with(&proj.style.site_file, bytes).await? { 117 | true => { 118 | trace!( 119 | "Style finished with changes {}", 120 | GRAY.paint(proj.style.site_file.to_string()) 121 | ); 122 | Product::Style("".to_string()) //TODO 123 | } 124 | false => { 125 | trace!("Style finished without changes"); 126 | Product::None 127 | } 128 | }; 129 | Ok(prod) 130 | } 131 | -------------------------------------------------------------------------------- /src/compile/tailwind.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8Path; 2 | use tokio::process::Command; 3 | 4 | use crate::{ 5 | config::{Project, TailwindConfig}, 6 | ext::{ 7 | eyre::CustomWrapErr, 8 | fs, 9 | sync::{wait_piped_interruptible, CommandResult, OutputExt}, 10 | Exe, Paint, 11 | }, 12 | internal_prelude::*, 13 | logger::GRAY, 14 | signal::{Interrupt, Outcome}, 15 | }; 16 | 17 | pub async fn compile_tailwind(proj: &Project, tw_conf: &TailwindConfig) -> Result<Outcome<String>> { 18 | if let Some(config_file) = tw_conf.config_file.as_ref() { 19 | if !config_file.exists() { 20 | create_default_tailwind_config(config_file).await? 21 | } 22 | } 23 | 24 | let (line, process) = tailwind_process(proj, "tailwindcss", tw_conf).await?; 25 | 26 | match wait_piped_interruptible("Tailwind", process, Interrupt::subscribe_any()).await? { 27 | CommandResult::Success(output) => { 28 | let done = output 29 | .stderr() 30 | .lines() 31 | .last() 32 | .map(|l| l.contains("Done")) 33 | .unwrap_or(false); 34 | 35 | if done { 36 | info!("Tailwind finished {}", GRAY.paint(line)); 37 | match fs::read_to_string(&tw_conf.tmp_file).await { 38 | Ok(content) => Ok(Outcome::Success(content)), 39 | Err(e) => { 40 | error!("Failed to read tailwind result: {e}"); 41 | Ok(Outcome::Failed) 42 | } 43 | } 44 | } else { 45 | warn!("Tailwind failed {}", GRAY.paint(line)); 46 | println!("{}\n{}", output.stdout(), output.stderr()); 47 | Ok(Outcome::Failed) 48 | } 49 | } 50 | CommandResult::Interrupted => Ok(Outcome::Stopped), 51 | CommandResult::Failure(output) => { 52 | warn!("Tailwind failed"); 53 | if output.has_stdout() { 54 | println!("{}", output.stdout()); 55 | } 56 | println!("{}", output.stderr()); 57 | Ok(Outcome::Failed) 58 | } 59 | } 60 | } 61 | 62 | async fn create_default_tailwind_config(config_file: &Utf8Path) -> Result<()> { 63 | let contents = r#"/** @type {import('tailwindcss').Config} */ 64 | module.exports = { 65 | content: { 66 | relative: true, 67 | files: ["*.html", "./src/**/*.rs"], 68 | }, 69 | theme: { 70 | extend: {}, 71 | }, 72 | plugins: [], 73 | } 74 | "#; 75 | fs::write(config_file, contents).await 76 | } 77 | 78 | pub async fn tailwind_process( 79 | proj: &Project, 80 | cmd: &str, 81 | tw_conf: &TailwindConfig, 82 | ) -> Result<(String, Command)> { 83 | let tailwind = Exe::Tailwind.get().await.dot()?; 84 | 85 | let mut args = vec!["--input", tw_conf.input_file.as_str()]; 86 | 87 | if let Some(config_file) = tw_conf.config_file.as_ref() { 88 | args.push("--config"); 89 | args.push(config_file.as_str()); 90 | } 91 | 92 | args.push("--output"); 93 | args.push(tw_conf.tmp_file.as_str()); 94 | 95 | if proj.release { 96 | // minify & optimize 97 | args.push("--minify"); 98 | } 99 | 100 | let line = format!("{} {}", cmd, args.join(" ")); 101 | let mut command = Command::new(tailwind); 102 | command.args(args); 103 | 104 | Ok((line, command)) 105 | } 106 | -------------------------------------------------------------------------------- /src/compile/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | compile::front::build_cargo_front_cmd, 3 | config::{Config, Opts}, 4 | }; 5 | use insta::assert_snapshot; 6 | use tokio::process::Command; 7 | 8 | use super::server::build_cargo_server_cmd; 9 | 10 | fn release_opts() -> Opts { 11 | Opts { 12 | release: true, 13 | js_minify: true, 14 | precompress: false, // if set to true, testing could take quite a while longer 15 | hot_reload: false, 16 | project: None, 17 | verbose: 0, 18 | features: Vec::new(), 19 | bin_features: Vec::new(), 20 | lib_features: Vec::new(), 21 | bin_cargo_args: None, 22 | lib_cargo_args: None, 23 | wasm_debug: false, 24 | } 25 | } 26 | fn dev_opts() -> Opts { 27 | Opts { 28 | release: false, 29 | js_minify: false, 30 | precompress: false, 31 | hot_reload: false, 32 | project: None, 33 | verbose: 0, 34 | features: Vec::new(), 35 | bin_features: Vec::new(), 36 | lib_features: Vec::new(), 37 | bin_cargo_args: None, 38 | lib_cargo_args: None, 39 | wasm_debug: false, 40 | } 41 | } 42 | 43 | #[test] 44 | fn test_project_dev() { 45 | let cli = dev_opts(); 46 | let conf = Config::test_load(cli, "examples", "examples/project/Cargo.toml", true, None); 47 | 48 | let mut command = Command::new("cargo"); 49 | let (envs, cargo) = build_cargo_server_cmd("build", &conf.projects[0], &mut command); 50 | 51 | const ENV_REF: &str = "\ 52 | LEPTOS_OUTPUT_NAME=example \ 53 | LEPTOS_SITE_ROOT=target/site \ 54 | LEPTOS_SITE_PKG_DIR=pkg \ 55 | LEPTOS_SITE_ADDR=127.0.0.1:3000 \ 56 | LEPTOS_RELOAD_PORT=3001 \ 57 | LEPTOS_LIB_DIR=. \ 58 | LEPTOS_BIN_DIR=. \ 59 | LEPTOS_JS_MINIFY=false \ 60 | LEPTOS_HASH_FILES=true \ 61 | LEPTOS_HASH_FILE_NAME=hash.txt \ 62 | LEPTOS_WATCH=true \ 63 | SERVER_FN_PREFIX=/custom/prefix \ 64 | DISABLE_SERVER_FN_HASH=true \ 65 | SERVER_FN_MOD_PATH=true \ 66 | RUSTFLAGS=--cfg erase_components"; 67 | assert_eq!(ENV_REF, envs); 68 | 69 | assert_snapshot!(cargo, @"cargo build --package=example --bin=example --no-default-features --features=ssr"); 70 | 71 | let mut command = Command::new("cargo"); 72 | let (_, cargo) = build_cargo_front_cmd("build", true, &conf.projects[0], &mut command); 73 | 74 | assert!(cargo.starts_with("cargo build --package=example --lib --target-dir=")); 75 | // what's in the middle will vary by platform and cwd 76 | assert!( 77 | cargo.ends_with("--target=wasm32-unknown-unknown --no-default-features --features=hydrate") 78 | ); 79 | } 80 | 81 | #[test] 82 | fn test_project_release() { 83 | let cli = release_opts(); 84 | let conf = Config::test_load(cli, "examples", "examples/project/Cargo.toml", true, None); 85 | 86 | let mut command = Command::new("cargo"); 87 | let (_, cargo) = build_cargo_server_cmd("build", &conf.projects[0], &mut command); 88 | 89 | assert_snapshot!(cargo, @"cargo build --package=example --bin=example --no-default-features --features=ssr --release"); 90 | 91 | let mut command = Command::new("cargo"); 92 | let (_, cargo) = build_cargo_front_cmd("build", true, &conf.projects[0], &mut command); 93 | 94 | assert!(cargo.starts_with("cargo build --package=example --lib --target-dir=")); 95 | // what's in the middle will vary by platform and cwd 96 | assert!(cargo.ends_with( 97 | "--target=wasm32-unknown-unknown --no-default-features --features=hydrate --release" 98 | )); 99 | } 100 | 101 | #[test] 102 | fn test_workspace_project1() { 103 | const ENV_REF: &str = if cfg!(windows) { 104 | "\ 105 | LEPTOS_OUTPUT_NAME=project1 \ 106 | LEPTOS_SITE_ROOT=target/site/project1 \ 107 | LEPTOS_SITE_PKG_DIR=pkg \ 108 | LEPTOS_SITE_ADDR=127.0.0.1:3000 \ 109 | LEPTOS_RELOAD_PORT=3001 \ 110 | LEPTOS_LIB_DIR=project1\\front \ 111 | LEPTOS_BIN_DIR=project1\\server \ 112 | LEPTOS_JS_MINIFY=false \ 113 | LEPTOS_HASH_FILES=false \ 114 | LEPTOS_WATCH=true \ 115 | SERVER_FN_PREFIX=/custom/prefix \ 116 | DISABLE_SERVER_FN_HASH=true \ 117 | SERVER_FN_MOD_PATH=true \ 118 | RUSTFLAGS=--cfg erase_components" 119 | } else { 120 | "\ 121 | LEPTOS_OUTPUT_NAME=project1 \ 122 | LEPTOS_SITE_ROOT=target/site/project1 \ 123 | LEPTOS_SITE_PKG_DIR=pkg \ 124 | LEPTOS_SITE_ADDR=127.0.0.1:3000 \ 125 | LEPTOS_RELOAD_PORT=3001 \ 126 | LEPTOS_LIB_DIR=project1/front \ 127 | LEPTOS_BIN_DIR=project1/server \ 128 | LEPTOS_JS_MINIFY=false \ 129 | LEPTOS_HASH_FILES=false \ 130 | LEPTOS_WATCH=true \ 131 | SERVER_FN_PREFIX=/custom/prefix \ 132 | DISABLE_SERVER_FN_HASH=true \ 133 | SERVER_FN_MOD_PATH=true \ 134 | RUSTFLAGS=--cfg erase_components" 135 | }; 136 | 137 | let cli = dev_opts(); 138 | let conf = Config::test_load(cli, "examples", "examples/workspace/Cargo.toml", true, None); 139 | 140 | let mut command = Command::new("cargo"); 141 | let (envs, cargo) = build_cargo_server_cmd("build", &conf.projects[0], &mut command); 142 | 143 | assert_eq!(ENV_REF, envs); 144 | 145 | assert_snapshot!(cargo, @"cargo build --package=server-package --bin=server-package --no-default-features"); 146 | 147 | let mut command = Command::new("cargo"); 148 | let (envs, cargo) = build_cargo_front_cmd("build", true, &conf.projects[0], &mut command); 149 | 150 | assert_eq!(ENV_REF, envs); 151 | 152 | assert!(cargo.starts_with("cargo build --package=front-package --lib --target-dir=")); 153 | // what's in the middle will vary by platform and cwd 154 | assert!(cargo.ends_with("--target=wasm32-unknown-unknown --no-default-features")); 155 | } 156 | 157 | #[test] 158 | fn test_workspace_project2() { 159 | let cli = dev_opts(); 160 | let conf = Config::test_load(cli, "examples", "examples/workspace/Cargo.toml", true, None); 161 | 162 | let mut command = Command::new("cargo"); 163 | let (_, cargo) = build_cargo_server_cmd("build", &conf.projects[1], &mut command); 164 | 165 | assert_snapshot!(cargo, @"cargo build --package=project2 --bin=project2 --no-default-features --features=ssr"); 166 | 167 | let mut command = Command::new("cargo"); 168 | let (_, cargo) = build_cargo_front_cmd("build", true, &conf.projects[1], &mut command); 169 | 170 | assert!(cargo.starts_with("cargo build --package=project2 --lib --target-dir=")); 171 | // what's in the middle will vary by platform and cwd 172 | assert!( 173 | cargo.ends_with("--target=wasm32-unknown-unknown --no-default-features --features=hydrate") 174 | ); 175 | } 176 | 177 | #[test] 178 | fn test_extra_cargo_args() { 179 | let cli = Opts { 180 | lib_cargo_args: Some(vec!["-j".into(), "8".into()]), 181 | bin_cargo_args: Some(vec!["-j".into(), "16".into()]), 182 | ..dev_opts() 183 | }; 184 | let conf = Config::test_load(cli, "examples", "examples/project/Cargo.toml", true, None); 185 | 186 | let mut command = Command::new("cargo"); 187 | let (_, cargo) = build_cargo_server_cmd("build", &conf.projects[0], &mut command); 188 | 189 | assert_snapshot!(cargo, @"cargo build --package=example --bin=example --no-default-features --features=ssr -j 16"); 190 | 191 | let mut command = Command::new("cargo"); 192 | let (_, cargo) = build_cargo_front_cmd("build", true, &conf.projects[0], &mut command); 193 | 194 | assert!(cargo.starts_with("cargo build --package=example --lib --target-dir=")); 195 | // what's in the middle will vary by platform and cwd 196 | assert!(cargo.ends_with( 197 | "--target=wasm32-unknown-unknown --no-default-features --features=hydrate -j 8" 198 | )); 199 | } 200 | -------------------------------------------------------------------------------- /src/config/assets.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | 3 | use crate::ext::PathBufExt; 4 | 5 | use super::ProjectConfig; 6 | 7 | pub struct AssetsConfig { 8 | pub dir: Utf8PathBuf, 9 | } 10 | 11 | impl AssetsConfig { 12 | pub fn resolve(config: &ProjectConfig) -> Option<Self> { 13 | let Some(assets_dir) = &config.assets_dir else { 14 | return None; 15 | }; 16 | 17 | Some(Self { 18 | // relative to the configuration file 19 | dir: config.config_dir.join(assets_dir), 20 | }) 21 | } 22 | } 23 | 24 | impl std::fmt::Debug for AssetsConfig { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | f.debug_struct("AssetsConfig") 27 | .field("dir", &self.dir.test_string()) 28 | .finish() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/config/bin_package.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | use cargo_metadata::{Metadata, Target}; 3 | 4 | use super::{project::ProjectDefinition, Profile, ProjectConfig}; 5 | use crate::internal_prelude::*; 6 | use crate::{ 7 | config::Opts, 8 | ext::{MetadataExt, PackageExt, PathBufExt, PathExt}, 9 | }; 10 | pub struct BinPackage { 11 | pub name: String, 12 | pub abs_dir: Utf8PathBuf, 13 | pub rel_dir: Utf8PathBuf, 14 | pub exe_file: Utf8PathBuf, 15 | pub target: String, 16 | pub features: Vec<String>, 17 | pub default_features: bool, 18 | /// all source paths, including path dependencies' 19 | pub src_paths: Vec<Utf8PathBuf>, 20 | pub profile: Profile, 21 | pub target_triple: Option<String>, 22 | pub target_dir: Option<String>, 23 | pub cargo_command: Option<String>, 24 | pub cargo_args: Option<Vec<String>>, 25 | pub bin_args: Option<Vec<String>>, 26 | } 27 | 28 | impl BinPackage { 29 | pub fn resolve( 30 | cli: &Opts, 31 | metadata: &Metadata, 32 | project: &ProjectDefinition, 33 | config: &ProjectConfig, 34 | bin_args: Option<&[String]>, 35 | ) -> Result<Self> { 36 | let mut features = if !cli.bin_features.is_empty() { 37 | cli.bin_features.clone() 38 | } else if !config.bin_features.is_empty() { 39 | config.bin_features.clone() 40 | } else { 41 | vec![] 42 | }; 43 | 44 | features.extend(config.features.clone()); 45 | features.extend(cli.features.clone()); 46 | 47 | let name = project.bin_package.clone(); 48 | let packages = metadata.workspace_packages(); 49 | let package = packages 50 | .iter() 51 | .find(|p| p.name == name && p.has_bin_target()) 52 | .ok_or_else(|| eyre!(r#"Could not find the project bin-package "{name}""#,))?; 53 | 54 | let package = (*package).clone(); 55 | 56 | let targets = package 57 | .targets 58 | .iter() 59 | .filter(|t| t.is_bin()) 60 | .collect::<Vec<&Target>>(); 61 | 62 | let target: Target = if !&config.bin_target.is_empty() { 63 | targets 64 | .into_iter() 65 | .find(|t| t.name == config.bin_target) 66 | .ok_or_else(|| target_not_found(config.bin_target.as_str()))? 67 | .clone() 68 | } else if targets.len() == 1 { 69 | targets[0].clone() 70 | } else if targets.is_empty() { 71 | bail!("No bin targets found for member {name}"); 72 | } else { 73 | return Err(many_targets_found(&name)); 74 | }; 75 | 76 | let abs_dir = package.manifest_path.clone().without_last(); 77 | let rel_dir = abs_dir.unbase(&metadata.workspace_root)?; 78 | let profile = Profile::new( 79 | cli.release, 80 | &config.bin_profile_release, 81 | &config.bin_profile_dev, 82 | ); 83 | let exe_file = { 84 | let file_ext = if cfg!(target_os = "windows") 85 | && config 86 | .bin_target_triple 87 | .as_ref() 88 | .is_none_or(|triple| triple.contains("-pc-windows-")) 89 | { 90 | "exe" 91 | } else if config 92 | .bin_target_triple 93 | .as_ref() 94 | .is_some_and(|target| target.starts_with("wasm32-")) 95 | { 96 | "wasm" 97 | } else { 98 | "" 99 | }; 100 | 101 | let mut file = config 102 | .bin_target_dir 103 | .as_ref() 104 | .map(|dir| dir.into()) 105 | // Can't use absolute path because the path gets stored in snapshot testing, and it differs between developers 106 | .unwrap_or_else(|| metadata.rel_target_dir()); 107 | if let Some(triple) = &config.bin_target_triple { 108 | file = file.join(triple) 109 | }; 110 | let name = if let Some(name) = &config.bin_exe_name { 111 | name 112 | } else { 113 | &name 114 | }; 115 | file.join(profile.to_string()) 116 | .join(name) 117 | .with_extension(file_ext) 118 | }; 119 | 120 | let mut src_paths = metadata.src_path_dependencies(&package.id); 121 | if rel_dir == "." { 122 | src_paths.push("src".into()); 123 | } else { 124 | src_paths.push(rel_dir.join("src")); 125 | } 126 | 127 | let cargo_args = cli 128 | .bin_cargo_args 129 | .clone() 130 | .or_else(|| config.bin_cargo_args.clone()); 131 | 132 | debug!("BEFORE BIN {:?}", config.bin_cargo_command); 133 | Ok(Self { 134 | name, 135 | abs_dir, 136 | rel_dir, 137 | exe_file, 138 | target: target.name, 139 | features, 140 | default_features: config.bin_default_features, 141 | src_paths, 142 | profile, 143 | target_triple: config.bin_target_triple.clone(), 144 | target_dir: config.bin_target_dir.clone(), 145 | cargo_command: config.bin_cargo_command.clone(), 146 | cargo_args, 147 | bin_args: bin_args.map(ToOwned::to_owned), 148 | }) 149 | } 150 | } 151 | 152 | impl std::fmt::Debug for BinPackage { 153 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 154 | f.debug_struct("BinPackage") 155 | .field("name", &self.name) 156 | .field("rel_dir", &self.rel_dir.test_string()) 157 | .field("exe_file", &self.exe_file.test_string()) 158 | .field("target", &self.target) 159 | .field("features", &self.features) 160 | .field("default_features", &self.default_features) 161 | .field( 162 | "src_paths", 163 | &self 164 | .src_paths 165 | .iter() 166 | .map(|p| p.test_string()) 167 | .collect::<Vec<_>>() 168 | .join(", "), 169 | ) 170 | .field("profile", &self.profile) 171 | .field("bin_args", &self.bin_args) 172 | .finish_non_exhaustive() 173 | } 174 | } 175 | 176 | fn many_targets_found(pkg: &str) -> Error { 177 | eyre!( 178 | r#"Several bin targets found for member "{pkg}", please specify which one to use with: [[workspace.metadata.leptos]] bin-target = "name""# 179 | ) 180 | } 181 | fn target_not_found(target: &str) -> Error { 182 | eyre!( 183 | r#"Could not find the target specified: [[workspace.metadata.leptos]] bin-target = "{target}""#, 184 | ) 185 | } 186 | -------------------------------------------------------------------------------- /src/config/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::command::NewCommand; 2 | use camino::Utf8PathBuf; 3 | use clap::{Parser, Subcommand, ValueEnum}; 4 | 5 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 6 | pub enum Log { 7 | /// WASM build (wasm, wasm-opt, walrus) 8 | Wasm, 9 | /// Internal reload and csr server (hyper, axum) 10 | Server, 11 | } 12 | 13 | #[derive(Debug, Clone, Parser, PartialEq, Default)] 14 | pub struct Opts { 15 | /// Build artifacts in release mode, with optimizations. 16 | #[arg(short, long)] 17 | pub release: bool, 18 | 19 | /// Precompress static assets with gzip and brotli. Applies to release builds only. 20 | #[arg(short = 'P', long)] 21 | pub precompress: bool, 22 | 23 | /// Turn on partial hot-reloading. Requires rust nightly [beta] 24 | #[arg(long)] 25 | pub hot_reload: bool, 26 | 27 | /// Which project to use, from a list of projects defined in a workspace 28 | #[arg(short, long)] 29 | pub project: Option<String>, 30 | 31 | /// The features to use when compiling all targets 32 | #[arg(long)] 33 | pub features: Vec<String>, 34 | 35 | /// The features to use when compiling the lib target 36 | #[arg(long)] 37 | pub lib_features: Vec<String>, 38 | 39 | /// The cargo flags to pass to cargo when compiling the lib target 40 | #[arg(long)] 41 | pub lib_cargo_args: Option<Vec<String>>, 42 | 43 | /// The features to use when compiling the bin target 44 | #[arg(long)] 45 | pub bin_features: Vec<String>, 46 | 47 | /// The cargo flags to pass to cargo when compiling the bin target 48 | #[arg(long)] 49 | pub bin_cargo_args: Option<Vec<String>>, 50 | 51 | /// Include debug information in Wasm output. Includes source maps and DWARF debug info. 52 | #[arg(long)] 53 | pub wasm_debug: bool, 54 | 55 | /// Verbosity (none: info, errors & warnings, -v: verbose, -vv: very verbose). 56 | #[arg(short, action = clap::ArgAction::Count)] 57 | pub verbose: u8, 58 | 59 | /// Minify javascript assets with swc. Applies to release builds only. 60 | #[arg(long, default_value = "true", value_parser=clap::builder::BoolishValueParser::new(), action = clap::ArgAction::Set)] 61 | pub js_minify: bool, 62 | } 63 | 64 | #[derive(Debug, Clone, Parser, PartialEq, Default)] 65 | pub struct BinOpts { 66 | #[command(flatten)] 67 | opts: Opts, 68 | 69 | #[arg(trailing_var_arg = true)] 70 | bin_args: Vec<String>, 71 | } 72 | 73 | #[derive(Debug, Parser)] 74 | #[clap(version)] 75 | pub struct Cli { 76 | /// Path to Cargo.toml. 77 | #[arg(long)] 78 | pub manifest_path: Option<Utf8PathBuf>, 79 | 80 | /// Output logs from dependencies (multiple --log accepted). 81 | #[arg(long)] 82 | pub log: Vec<Log>, 83 | 84 | #[command(subcommand)] 85 | pub command: Commands, 86 | } 87 | 88 | impl Cli { 89 | pub fn opts(&self) -> Option<Opts> { 90 | match &self.command { 91 | Commands::New(_) => None, 92 | Commands::Serve(bin_opts) | Commands::Watch(bin_opts) => Some(bin_opts.opts.clone()), 93 | Commands::Build(opts) | Commands::Test(opts) | Commands::EndToEnd(opts) => { 94 | Some(opts.clone()) 95 | } 96 | } 97 | } 98 | 99 | pub fn opts_mut(&mut self) -> Option<&mut Opts> { 100 | match &mut self.command { 101 | Commands::New(_) => None, 102 | Commands::Serve(bin_opts) | Commands::Watch(bin_opts) => Some(&mut bin_opts.opts), 103 | Commands::Build(opts) | Commands::Test(opts) | Commands::EndToEnd(opts) => Some(opts), 104 | } 105 | } 106 | 107 | pub fn bin_args(&self) -> Option<&[String]> { 108 | match &self.command { 109 | Commands::Serve(bin_opts) | Commands::Watch(bin_opts) => { 110 | Some(bin_opts.bin_args.as_ref()) 111 | } 112 | _ => None, 113 | } 114 | } 115 | } 116 | 117 | #[derive(Debug, Subcommand, PartialEq)] 118 | pub enum Commands { 119 | /// Build the server (feature ssr) and the client (wasm with feature hydrate). 120 | Build(Opts), 121 | /// Run the cargo tests for app, client and server. 122 | Test(Opts), 123 | /// Start the server and end-2-end tests. 124 | EndToEnd(Opts), 125 | /// Serve. Defaults to hydrate mode. 126 | Serve(BinOpts), 127 | /// Serve and automatically reload when files change. 128 | Watch(BinOpts), 129 | /// Start a wizard for creating a new project (using cargo-generate). 130 | New(NewCommand), 131 | } 132 | -------------------------------------------------------------------------------- /src/config/dotenvs.rs: -------------------------------------------------------------------------------- 1 | use super::{ProjectConfig, ENV_VAR_LEPTOS_SASS_VERSION, ENV_VAR_LEPTOS_TAILWIND_VERSION}; 2 | use crate::internal_prelude::*; 3 | use camino::{Utf8Path, Utf8PathBuf}; 4 | use std::{env, fs}; 5 | 6 | pub fn load_dotenvs(directory: &Utf8Path) -> Result<Option<Vec<(String, String)>>> { 7 | let candidate = directory.join(".env"); 8 | 9 | if let Ok(metadata) = fs::metadata(&candidate) { 10 | if metadata.is_file() { 11 | let mut dotenvs = vec![]; 12 | for entry in dotenvy::from_path_iter(&candidate)? { 13 | let (key, val) = entry?; 14 | dotenvs.push((key, val)); 15 | } 16 | 17 | return Ok(Some(dotenvs)); 18 | } 19 | } 20 | 21 | if let Some(parent) = directory.parent() { 22 | load_dotenvs(parent) 23 | } else { 24 | Ok(None) 25 | } 26 | } 27 | 28 | pub fn overlay_env(conf: &mut ProjectConfig, dotenvs: Option<Vec<(String, String)>>) -> Result<()> { 29 | if let Some(dotenvs) = dotenvs { 30 | overlay(conf, dotenvs.into_iter())?; 31 | } 32 | overlay(conf, env::vars())?; 33 | Ok(()) 34 | } 35 | 36 | fn overlay(conf: &mut ProjectConfig, envs: impl Iterator<Item = (String, String)>) -> Result<()> { 37 | for (key, val) in envs { 38 | match key.as_str() { 39 | "LEPTOS_OUTPUT_NAME" => conf.output_name = val, 40 | "LEPTOS_SITE_ROOT" => conf.site_root = Utf8PathBuf::from(val), 41 | "LEPTOS_SITE_PKG_DIR" => conf.site_pkg_dir = Utf8PathBuf::from(val), 42 | "LEPTOS_STYLE_FILE" => conf.style_file = Some(Utf8PathBuf::from(val)), 43 | "LEPTOS_ASSETS_DIR" => conf.assets_dir = Some(Utf8PathBuf::from(val)), 44 | "LEPTOS_SITE_ADDR" => conf.site_addr = val.parse()?, 45 | "LEPTOS_RELOAD_PORT" => conf.reload_port = val.parse()?, 46 | "LEPTOS_END2END_CMD" => conf.end2end_cmd = Some(val), 47 | "LEPTOS_END2END_DIR" => conf.end2end_dir = Some(Utf8PathBuf::from(val)), 48 | "LEPTOS_HASH_FILES" => conf.hash_files = val.parse()?, 49 | "LEPTOS_HASH_FILE_NAME" => conf.hash_file_name = Some(val.parse()?), 50 | "LEPTOS_BROWSERQUERY" => conf.browserquery = val, 51 | "LEPTOS_BIN_EXE_NAME" => conf.bin_exe_name = Some(val), 52 | "LEPTOS_BIN_TARGET" => conf.bin_target = val, 53 | "LEPTOS_BIN_TARGET_TRIPLE" => conf.bin_target_triple = Some(val), 54 | "LEPTOS_BIN_TARGET_DIR" => conf.bin_target_dir = Some(val), 55 | "LEPTOS_BIN_CARGO_COMMAND" => conf.bin_cargo_command = Some(val), 56 | "LEPTOS_JS_MINIFY" => conf.js_minify = val.parse()?, 57 | "SERVER_FN_PREFIX" => conf.server_fn_prefix = Some(val), 58 | "DISABLE_SERVER_FN_HASH" => conf.disable_server_fn_hash = true, 59 | // put these here to suppress the warning, but there's no 60 | // good way at the moment to pull the ProjectConfig all the way to Exe 61 | ENV_VAR_LEPTOS_TAILWIND_VERSION => {} 62 | ENV_VAR_LEPTOS_SASS_VERSION => {} 63 | _ if key.starts_with("LEPTOS_") => { 64 | warn!("Env {key} is not used by cargo-leptos") 65 | } 66 | _ => {} 67 | } 68 | } 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/config/end2end.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | 3 | use crate::ext::PathBufExt; 4 | 5 | use super::ProjectConfig; 6 | 7 | pub struct End2EndConfig { 8 | pub cmd: String, 9 | pub dir: Utf8PathBuf, 10 | } 11 | 12 | impl End2EndConfig { 13 | pub fn resolve(config: &ProjectConfig) -> Option<Self> { 14 | let cmd = &config.end2end_cmd.to_owned()?; 15 | 16 | let dir = config.end2end_dir.to_owned().unwrap_or_default(); 17 | 18 | Some(Self { 19 | cmd: cmd.clone(), 20 | dir, 21 | }) 22 | } 23 | } 24 | 25 | impl std::fmt::Debug for End2EndConfig { 26 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 | f.debug_struct("") 28 | .field("cmd", &self.cmd) 29 | .field("dir", &self.dir.test_string()) 30 | .finish() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/config/hash_file.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | 3 | use super::bin_package::BinPackage; 4 | use crate::internal_prelude::*; 5 | 6 | pub struct HashFile { 7 | pub abs: Utf8PathBuf, 8 | pub rel: Utf8PathBuf, 9 | } 10 | 11 | impl HashFile { 12 | pub fn new( 13 | workspace_root: Option<&Utf8PathBuf>, 14 | bin: &BinPackage, 15 | rel: Option<&Utf8PathBuf>, 16 | ) -> Self { 17 | let rel = rel 18 | .cloned() 19 | .unwrap_or(Utf8PathBuf::from("hash.txt".to_string())); 20 | 21 | let exe_file_dir = bin.exe_file.parent().unwrap(); 22 | let abs; 23 | if let Some(workspace_root) = workspace_root { 24 | debug!("BIN PARENT: {}", bin.exe_file.parent().unwrap()); 25 | abs = workspace_root.join(exe_file_dir).join(&rel); 26 | } else { 27 | abs = bin.abs_dir.join(exe_file_dir).join(&rel); 28 | } 29 | Self { abs, rel } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config/lib_package.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use crate::{ 3 | config::Opts, 4 | ext::{MetadataExt, PathBufExt, PathExt}, 5 | service::site::{SiteFile, SourcedSiteFile}, 6 | }; 7 | use camino::Utf8PathBuf; 8 | use cargo_metadata::Metadata; 9 | 10 | use super::{project::ProjectDefinition, Profile, ProjectConfig}; 11 | 12 | pub struct LibPackage { 13 | pub name: String, 14 | /// absolute dir to package 15 | pub abs_dir: Utf8PathBuf, 16 | pub rel_dir: Utf8PathBuf, 17 | pub wasm_file: SourcedSiteFile, 18 | pub js_file: SiteFile, 19 | pub features: Vec<String>, 20 | pub default_features: bool, 21 | pub output_name: String, 22 | pub src_paths: Vec<Utf8PathBuf>, 23 | pub front_target_path: Utf8PathBuf, 24 | pub profile: Profile, 25 | pub cargo_args: Option<Vec<String>>, 26 | } 27 | 28 | impl LibPackage { 29 | pub fn resolve( 30 | cli: &Opts, 31 | metadata: &Metadata, 32 | project: &ProjectDefinition, 33 | config: &ProjectConfig, 34 | ) -> Result<Self> { 35 | let name = project.lib_package.clone(); 36 | let packages = metadata.workspace_packages(); 37 | let output_name = if !config.output_name.is_empty() { 38 | config.output_name.clone() 39 | } else { 40 | name.replace('-', "_") 41 | }; 42 | 43 | let package = packages 44 | .iter() 45 | .find(|p| p.name == *name) 46 | .ok_or_else(|| eyre!(r#"Could not find the project lib-package "{name}""#,))?; 47 | 48 | let mut features = if !cli.lib_features.is_empty() { 49 | cli.lib_features.clone() 50 | } else if !config.lib_features.is_empty() { 51 | config.lib_features.clone() 52 | } else { 53 | vec![] 54 | }; 55 | 56 | features.extend(config.features.clone()); 57 | features.extend(cli.features.clone()); 58 | 59 | let abs_dir = package.manifest_path.clone().without_last(); 60 | let rel_dir = abs_dir.unbase(&metadata.workspace_root)?; 61 | let profile = Profile::new( 62 | cli.release, 63 | &config.lib_profile_release, 64 | &config.lib_profile_dev, 65 | ); 66 | 67 | let wasm_file = { 68 | let source = metadata 69 | .rel_target_dir() 70 | .join("front") 71 | .join("wasm32-unknown-unknown") 72 | .join(profile.to_string()) 73 | .join(name.replace('-', "_")) 74 | .with_extension("wasm"); 75 | let site = config 76 | .site_pkg_dir 77 | .join(&output_name) 78 | .with_extension("wasm"); 79 | let dest = config.site_root.join(&site); 80 | SourcedSiteFile { source, dest, site } 81 | }; 82 | 83 | let js_file = { 84 | let site = config.site_pkg_dir.join(&output_name).with_extension("js"); 85 | let dest = config.site_root.join(&site); 86 | SiteFile { dest, site } 87 | }; 88 | 89 | let mut src_deps = metadata.src_path_dependencies(&package.id); 90 | if rel_dir == "." { 91 | src_deps.push("src".into()); 92 | } else { 93 | src_deps.push(rel_dir.join("src")); 94 | } 95 | 96 | let front_target_path = metadata.target_directory.join("front"); 97 | let cargo_args = cli 98 | .lib_cargo_args 99 | .clone() 100 | .or_else(|| config.lib_cargo_args.clone()); 101 | 102 | Ok(Self { 103 | name, 104 | abs_dir, 105 | rel_dir, 106 | wasm_file, 107 | js_file, 108 | features, 109 | default_features: config.lib_default_features, 110 | output_name, 111 | src_paths: src_deps, 112 | front_target_path, 113 | profile, 114 | cargo_args, 115 | }) 116 | } 117 | } 118 | 119 | impl std::fmt::Debug for LibPackage { 120 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 121 | f.debug_struct("LibPackage") 122 | .field("name", &self.name) 123 | .field("rel_dir", &self.rel_dir.test_string()) 124 | .field("wasm_file", &self.wasm_file) 125 | .field("js_file", &self.js_file) 126 | .field("features", &self.features) 127 | .field("default_features", &self.default_features) 128 | .field("output_name", &self.output_name) 129 | .field( 130 | "src_paths", 131 | &self 132 | .src_paths 133 | .iter() 134 | .map(|p| p.test_string()) 135 | .collect::<Vec<_>>() 136 | .join(", "), 137 | ) 138 | .field("profile", &self.profile) 139 | .finish_non_exhaustive() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests; 3 | 4 | mod assets; 5 | mod bin_package; 6 | mod cli; 7 | mod dotenvs; 8 | mod end2end; 9 | mod hash_file; 10 | mod lib_package; 11 | mod profile; 12 | mod project; 13 | mod style; 14 | mod tailwind; 15 | mod version; 16 | 17 | use std::{fmt::Debug, sync::Arc}; 18 | 19 | pub use self::cli::{Cli, Commands, Log, Opts}; 20 | use crate::ext::MetadataExt; 21 | use crate::internal_prelude::*; 22 | use camino::{Utf8Path, Utf8PathBuf}; 23 | use cargo_metadata::Metadata; 24 | pub use profile::Profile; 25 | pub use project::{Project, ProjectConfig}; 26 | pub use style::StyleConfig; 27 | pub use tailwind::TailwindConfig; 28 | pub use version::*; 29 | 30 | pub struct Config { 31 | /// absolute path to the working dir 32 | pub working_dir: Utf8PathBuf, 33 | pub projects: Vec<Arc<Project>>, 34 | pub cli: Opts, 35 | pub watch: bool, 36 | } 37 | 38 | impl Debug for Config { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | f.debug_struct("Config") 41 | .field("projects", &self.projects) 42 | .field("cli", &self.cli) 43 | .field("watch", &self.watch) 44 | .finish_non_exhaustive() 45 | } 46 | } 47 | 48 | impl Config { 49 | pub fn load( 50 | cli: Opts, 51 | cwd: &Utf8Path, 52 | manifest_path: &Utf8Path, 53 | watch: bool, 54 | bin_args: Option<&[String]>, 55 | ) -> Result<Self> { 56 | let metadata = Metadata::load_cleaned(manifest_path)?; 57 | 58 | let mut projects = Project::resolve(&cli, cwd, &metadata, watch, bin_args).dot()?; 59 | 60 | if projects.is_empty() { 61 | bail!("Please define leptos projects in the workspace Cargo.toml sections [[workspace.metadata.leptos]]") 62 | } 63 | 64 | if let Some(proj_name) = &cli.project { 65 | if let Some(proj) = projects.iter().find(|p| p.name == *proj_name) { 66 | projects = vec![proj.clone()]; 67 | } else { 68 | bail!( 69 | r#"The specified project "{proj_name}" not found. Available projects: {}"#, 70 | names(&projects) 71 | ) 72 | } 73 | } 74 | 75 | Ok(Self { 76 | working_dir: metadata.workspace_root, 77 | projects, 78 | cli, 79 | watch, 80 | }) 81 | } 82 | 83 | #[cfg(test)] 84 | pub fn test_load( 85 | cli: Opts, 86 | cwd: &str, 87 | manifest_path: &str, 88 | watch: bool, 89 | bin_args: Option<&[String]>, 90 | ) -> Self { 91 | use crate::ext::PathBufExt; 92 | 93 | let manifest_path = Utf8PathBuf::from(manifest_path) 94 | .canonicalize_utf8() 95 | .unwrap(); 96 | let mut cwd = Utf8PathBuf::from(cwd).canonicalize_utf8().unwrap(); 97 | cwd.clean_windows_path(); 98 | Self::load(cli, &cwd, &manifest_path, watch, bin_args).unwrap() 99 | } 100 | 101 | pub fn current_project(&self) -> Result<Arc<Project>> { 102 | if self.projects.len() == 1 { 103 | Ok(self.projects[0].clone()) 104 | } else { 105 | bail!("There are several projects available ({}). Please select one of them with the command line parameter --project", names(&self.projects)); 106 | } 107 | } 108 | } 109 | 110 | fn names(projects: &[Arc<Project>]) -> String { 111 | projects 112 | .iter() 113 | .map(|p| p.name.clone()) 114 | .collect::<Vec<_>>() 115 | .join(", ") 116 | } 117 | -------------------------------------------------------------------------------- /src/config/profile.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | #[derive(Debug)] 4 | pub enum Profile { 5 | Debug, 6 | Release, 7 | Named(String), 8 | } 9 | 10 | impl fmt::Display for Profile { 11 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 12 | match self { 13 | Self::Debug => write!(f, "debug"), 14 | Self::Release => write!(f, "release"), 15 | Self::Named(name) => write!(f, "{}", name), 16 | } 17 | } 18 | } 19 | 20 | impl Profile { 21 | pub fn new(is_release: bool, release: &Option<String>, debug: &Option<String>) -> Self { 22 | if is_release { 23 | if let Some(release) = release { 24 | Self::Named(release.clone()) 25 | } else { 26 | Self::Release 27 | } 28 | } else if let Some(debug) = debug { 29 | Self::Named(debug.clone()) 30 | } else { 31 | Self::Debug 32 | } 33 | } 34 | 35 | pub fn add_to_args(&self, args: &mut Vec<String>) { 36 | match self { 37 | Self::Debug => {} 38 | Self::Release => { 39 | args.push("--release".to_string()); 40 | } 41 | Self::Named(name) => { 42 | args.push(format!("--profile={}", name)); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__project.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "example", 9 | lib: LibPackage { 10 | name: "example", 11 | rel_dir: ".", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/example.wasm", 14 | dest: "target/site/pkg/example.wasm", 15 | site: "pkg/example.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/pkg/example.js", 19 | site: "pkg/example.js", 20 | }, 21 | features: [ 22 | "hydrate", 23 | ], 24 | default_features: false, 25 | output_name: "example", 26 | src_paths: "src", 27 | profile: Debug, 28 | .. 29 | }, 30 | bin: BinPackage { 31 | name: "example", 32 | rel_dir: ".", 33 | exe_file: "target/debug/example", 34 | target: "example", 35 | features: [ 36 | "ssr", 37 | ], 38 | default_features: false, 39 | src_paths: "src", 40 | profile: Debug, 41 | .. 42 | }, 43 | style: StyleConfig { 44 | file: Some( 45 | SourcedSiteFile { 46 | source: "style/main.scss", 47 | dest: "target/site/pkg/example.css", 48 | site: "pkg/example.css", 49 | }, 50 | ), 51 | browserquery: "defaults", 52 | tailwind: Some( 53 | TailwindConfig { 54 | input_file: "style/tailwind.css", 55 | config_file: "tailwind.config.js", 56 | tmp_file: "target/tmp/tailwind.css", 57 | }, 58 | ), 59 | site_file: SiteFile { 60 | dest: "target/site/pkg/example.css", 61 | site: "pkg/example.css", 62 | }, 63 | }, 64 | watch: true, 65 | release: false, 66 | precompress: false, 67 | hot_reload: false, 68 | site: Site { 69 | addr: 127.0.0.1:3000, 70 | reload: 127.0.0.1:3001, 71 | root_dir: "target/site", 72 | pkg_dir: "pkg", 73 | file_reg: {}, 74 | ext_file_reg: {}, 75 | }, 76 | end2end: Some( 77 | { 78 | cmd: "npx playwright test", 79 | dir: "end2end", 80 | }, 81 | ), 82 | assets: Some( 83 | AssetsConfig { 84 | dir: "assets", 85 | }, 86 | ), 87 | .. 88 | }, 89 | ], 90 | cli: Opts { 91 | release: false, 92 | precompress: false, 93 | hot_reload: false, 94 | project: None, 95 | features: [], 96 | lib_features: [], 97 | lib_cargo_args: None, 98 | bin_features: [], 99 | bin_cargo_args: None, 100 | verbose: 0, 101 | }, 102 | watch: true, 103 | .. 104 | } 105 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__workspace.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "project1", 9 | lib: LibPackage { 10 | name: "front-package", 11 | rel_dir: "project1/front", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/front_package.wasm", 14 | dest: "target/site/project1/pkg/project1.wasm", 15 | site: "pkg/project1.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/project1/pkg/project1.js", 19 | site: "pkg/project1.js", 20 | }, 21 | features: [], 22 | default_features: false, 23 | output_name: "project1", 24 | src_paths: "project1/app/src, project1/front/src", 25 | profile: Debug, 26 | .. 27 | }, 28 | bin: BinPackage { 29 | name: "server-package", 30 | rel_dir: "project1/server", 31 | exe_file: "target/debug/server-package", 32 | target: "server-package", 33 | features: [], 34 | default_features: false, 35 | src_paths: "project1/app/src, project1/server/src", 36 | profile: Debug, 37 | bin_args: None, 38 | .. 39 | }, 40 | style: StyleConfig { 41 | file: Some( 42 | SourcedSiteFile { 43 | source: "project1/css/main.scss", 44 | dest: "target/site/project1/pkg/project1.css", 45 | site: "pkg/project1.css", 46 | }, 47 | ), 48 | browserquery: "defaults", 49 | tailwind: None, 50 | site_file: SiteFile { 51 | dest: "target/site/project1/pkg/project1.css", 52 | site: "pkg/project1.css", 53 | }, 54 | }, 55 | watch: true, 56 | release: false, 57 | precompress: false, 58 | js_minify: false, 59 | hot_reload: false, 60 | site: Site { 61 | addr: 127.0.0.1:3000, 62 | reload: 127.0.0.1:3001, 63 | root_dir: "target/site/project1", 64 | pkg_dir: "pkg", 65 | file_reg: {}, 66 | ext_file_reg: {}, 67 | }, 68 | end2end: None, 69 | assets: Some( 70 | AssetsConfig { 71 | dir: "project1/assets", 72 | }, 73 | ), 74 | server_fn_prefix: Some( 75 | "/custom/prefix", 76 | ), 77 | disable_server_fn_hash: true, 78 | disable_erase_components: false, 79 | always_erase_components: false, 80 | server_fn_mod_path: true, 81 | .. 82 | }, 83 | Project { 84 | name: "project2", 85 | lib: LibPackage { 86 | name: "project2", 87 | rel_dir: "project2", 88 | wasm_file: SourcedSiteFile { 89 | source: "target/front/wasm32-unknown-unknown/debug/project2.wasm", 90 | dest: "target/site/project2/pkg/project2.wasm", 91 | site: "pkg/project2.wasm", 92 | }, 93 | js_file: SiteFile { 94 | dest: "target/site/project2/pkg/project2.js", 95 | site: "pkg/project2.js", 96 | }, 97 | features: [ 98 | "hydrate", 99 | ], 100 | default_features: false, 101 | output_name: "project2", 102 | src_paths: "project2/src", 103 | profile: Debug, 104 | .. 105 | }, 106 | bin: BinPackage { 107 | name: "project2", 108 | rel_dir: "project2", 109 | exe_file: "target/debug/project2", 110 | target: "project2", 111 | features: [ 112 | "ssr", 113 | ], 114 | default_features: false, 115 | src_paths: "project2/src", 116 | profile: Debug, 117 | bin_args: None, 118 | .. 119 | }, 120 | style: StyleConfig { 121 | file: Some( 122 | SourcedSiteFile { 123 | source: "project2/src/main.scss", 124 | dest: "target/site/project2/pkg/project2.css", 125 | site: "pkg/project2.css", 126 | }, 127 | ), 128 | browserquery: "defaults", 129 | tailwind: None, 130 | site_file: SiteFile { 131 | dest: "target/site/project2/pkg/project2.css", 132 | site: "pkg/project2.css", 133 | }, 134 | }, 135 | watch: true, 136 | release: false, 137 | precompress: false, 138 | js_minify: false, 139 | hot_reload: false, 140 | site: Site { 141 | addr: 127.0.0.1:3000, 142 | reload: 127.0.0.1:3001, 143 | root_dir: "target/site/project2", 144 | pkg_dir: "pkg", 145 | file_reg: {}, 146 | ext_file_reg: {}, 147 | }, 148 | end2end: None, 149 | assets: Some( 150 | AssetsConfig { 151 | dir: "project2/src/assets", 152 | }, 153 | ), 154 | server_fn_prefix: None, 155 | disable_server_fn_hash: false, 156 | disable_erase_components: false, 157 | always_erase_components: false, 158 | server_fn_mod_path: false, 159 | .. 160 | }, 161 | ], 162 | cli: Opts { 163 | release: false, 164 | precompress: false, 165 | hot_reload: false, 166 | project: None, 167 | features: [], 168 | lib_features: [], 169 | lib_cargo_args: None, 170 | bin_features: [], 171 | bin_cargo_args: None, 172 | wasm_debug: false, 173 | verbose: 0, 174 | js_minify: false, 175 | }, 176 | watch: true, 177 | .. 178 | } 179 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__workspace_bin_args_project2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "project2", 9 | lib: LibPackage { 10 | name: "project2", 11 | rel_dir: "project2", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/project2.wasm", 14 | dest: "target/site/project2/pkg/project2.wasm", 15 | site: "pkg/project2.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/project2/pkg/project2.js", 19 | site: "pkg/project2.js", 20 | }, 21 | features: [ 22 | "hydrate", 23 | ], 24 | default_features: false, 25 | output_name: "project2", 26 | src_paths: "project2/src", 27 | profile: Debug, 28 | .. 29 | }, 30 | bin: BinPackage { 31 | name: "project2", 32 | rel_dir: "project2", 33 | exe_file: "target/debug/project2", 34 | target: "project2", 35 | features: [ 36 | "ssr", 37 | ], 38 | default_features: false, 39 | src_paths: "project2/src", 40 | profile: Debug, 41 | bin_args: Some( 42 | [ 43 | "--", 44 | "--foo", 45 | ], 46 | ), 47 | .. 48 | }, 49 | style: StyleConfig { 50 | file: Some( 51 | SourcedSiteFile { 52 | source: "project2/src/main.scss", 53 | dest: "target/site/project2/pkg/project2.css", 54 | site: "pkg/project2.css", 55 | }, 56 | ), 57 | browserquery: "defaults", 58 | tailwind: None, 59 | site_file: SiteFile { 60 | dest: "target/site/project2/pkg/project2.css", 61 | site: "pkg/project2.css", 62 | }, 63 | }, 64 | watch: true, 65 | release: false, 66 | precompress: false, 67 | js_minify: false, 68 | hot_reload: false, 69 | site: Site { 70 | addr: 127.0.0.1:3000, 71 | reload: 127.0.0.1:3001, 72 | root_dir: "target/site/project2", 73 | pkg_dir: "pkg", 74 | file_reg: {}, 75 | ext_file_reg: {}, 76 | }, 77 | end2end: None, 78 | assets: Some( 79 | AssetsConfig { 80 | dir: "project2/src/assets", 81 | }, 82 | ), 83 | server_fn_prefix: None, 84 | disable_server_fn_hash: false, 85 | disable_erase_components: false, 86 | always_erase_components: false, 87 | server_fn_mod_path: false, 88 | .. 89 | }, 90 | ], 91 | cli: Opts { 92 | release: false, 93 | precompress: false, 94 | hot_reload: false, 95 | project: Some( 96 | "project2", 97 | ), 98 | features: [], 99 | lib_features: [], 100 | lib_cargo_args: None, 101 | bin_features: [], 102 | bin_cargo_args: None, 103 | wasm_debug: false, 104 | verbose: 0, 105 | js_minify: false, 106 | }, 107 | watch: true, 108 | .. 109 | } 110 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__workspace_in_subdir_project2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "project2", 9 | lib: LibPackage { 10 | name: "project2", 11 | rel_dir: "project2", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/project2.wasm", 14 | dest: "target/site/project2/pkg/project2.wasm", 15 | site: "pkg/project2.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/project2/pkg/project2.js", 19 | site: "pkg/project2.js", 20 | }, 21 | features: [ 22 | "hydrate", 23 | ], 24 | default_features: false, 25 | output_name: "project2", 26 | src_paths: "project2/src", 27 | profile: Debug, 28 | .. 29 | }, 30 | bin: BinPackage { 31 | name: "project2", 32 | rel_dir: "project2", 33 | exe_file: "target/debug/project2", 34 | target: "project2", 35 | features: [ 36 | "ssr", 37 | ], 38 | default_features: false, 39 | src_paths: "project2/src", 40 | profile: Debug, 41 | bin_args: None, 42 | .. 43 | }, 44 | style: StyleConfig { 45 | file: Some( 46 | SourcedSiteFile { 47 | source: "project2/src/main.scss", 48 | dest: "target/site/project2/pkg/project2.css", 49 | site: "pkg/project2.css", 50 | }, 51 | ), 52 | browserquery: "defaults", 53 | tailwind: None, 54 | site_file: SiteFile { 55 | dest: "target/site/project2/pkg/project2.css", 56 | site: "pkg/project2.css", 57 | }, 58 | }, 59 | watch: true, 60 | release: false, 61 | precompress: false, 62 | js_minify: false, 63 | hot_reload: false, 64 | site: Site { 65 | addr: 127.0.0.1:3000, 66 | reload: 127.0.0.1:3001, 67 | root_dir: "target/site/project2", 68 | pkg_dir: "pkg", 69 | file_reg: {}, 70 | ext_file_reg: {}, 71 | }, 72 | end2end: None, 73 | assets: Some( 74 | AssetsConfig { 75 | dir: "project2/src/assets", 76 | }, 77 | ), 78 | server_fn_prefix: None, 79 | disable_server_fn_hash: false, 80 | disable_erase_components: false, 81 | always_erase_components: false, 82 | server_fn_mod_path: false, 83 | .. 84 | }, 85 | ], 86 | cli: Opts { 87 | release: false, 88 | precompress: false, 89 | hot_reload: false, 90 | project: None, 91 | features: [], 92 | lib_features: [], 93 | lib_cargo_args: None, 94 | bin_features: [], 95 | bin_cargo_args: None, 96 | wasm_debug: false, 97 | verbose: 0, 98 | js_minify: false, 99 | }, 100 | watch: true, 101 | .. 102 | } 103 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__workspace_project1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "project1", 9 | lib: LibPackage { 10 | name: "front-package", 11 | rel_dir: "project1/front", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/front_package.wasm", 14 | dest: "target/site/project1/pkg/project1.wasm", 15 | site: "pkg/project1.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/project1/pkg/project1.js", 19 | site: "pkg/project1.js", 20 | }, 21 | features: [], 22 | default_features: false, 23 | output_name: "project1", 24 | src_paths: "project1/app/src, project1/front/src", 25 | profile: Debug, 26 | .. 27 | }, 28 | bin: BinPackage { 29 | name: "server-package", 30 | rel_dir: "project1/server", 31 | exe_file: "target/debug/server-package", 32 | target: "server-package", 33 | features: [], 34 | default_features: false, 35 | src_paths: "project1/app/src, project1/server/src", 36 | profile: Debug, 37 | bin_args: None, 38 | .. 39 | }, 40 | style: StyleConfig { 41 | file: Some( 42 | SourcedSiteFile { 43 | source: "project1/css/main.scss", 44 | dest: "target/site/project1/pkg/project1.css", 45 | site: "pkg/project1.css", 46 | }, 47 | ), 48 | browserquery: "defaults", 49 | tailwind: None, 50 | site_file: SiteFile { 51 | dest: "target/site/project1/pkg/project1.css", 52 | site: "pkg/project1.css", 53 | }, 54 | }, 55 | watch: true, 56 | release: false, 57 | precompress: false, 58 | js_minify: false, 59 | hot_reload: false, 60 | site: Site { 61 | addr: 127.0.0.1:3000, 62 | reload: 127.0.0.1:3001, 63 | root_dir: "target/site/project1", 64 | pkg_dir: "pkg", 65 | file_reg: {}, 66 | ext_file_reg: {}, 67 | }, 68 | end2end: None, 69 | assets: Some( 70 | AssetsConfig { 71 | dir: "project1/assets", 72 | }, 73 | ), 74 | server_fn_prefix: Some( 75 | "/custom/prefix", 76 | ), 77 | disable_server_fn_hash: true, 78 | disable_erase_components: false, 79 | always_erase_components: false, 80 | server_fn_mod_path: true, 81 | .. 82 | }, 83 | ], 84 | cli: Opts { 85 | release: false, 86 | precompress: false, 87 | hot_reload: false, 88 | project: Some( 89 | "project1", 90 | ), 91 | features: [], 92 | lib_features: [], 93 | lib_cargo_args: None, 94 | bin_features: [], 95 | bin_cargo_args: None, 96 | wasm_debug: false, 97 | verbose: 0, 98 | js_minify: false, 99 | }, 100 | watch: true, 101 | .. 102 | } 103 | -------------------------------------------------------------------------------- /src/config/snapshots/cargo_leptos__config__tests__workspace_project2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config/tests.rs 3 | expression: conf 4 | --- 5 | Config { 6 | projects: [ 7 | Project { 8 | name: "project2", 9 | lib: LibPackage { 10 | name: "project2", 11 | rel_dir: "project2", 12 | wasm_file: SourcedSiteFile { 13 | source: "target/front/wasm32-unknown-unknown/debug/project2.wasm", 14 | dest: "target/site/project2/pkg/project2.wasm", 15 | site: "pkg/project2.wasm", 16 | }, 17 | js_file: SiteFile { 18 | dest: "target/site/project2/pkg/project2.js", 19 | site: "pkg/project2.js", 20 | }, 21 | features: [ 22 | "hydrate", 23 | ], 24 | default_features: false, 25 | output_name: "project2", 26 | src_paths: "project2/src", 27 | profile: Debug, 28 | .. 29 | }, 30 | bin: BinPackage { 31 | name: "project2", 32 | rel_dir: "project2", 33 | exe_file: "target/debug/project2", 34 | target: "project2", 35 | features: [ 36 | "ssr", 37 | ], 38 | default_features: false, 39 | src_paths: "project2/src", 40 | profile: Debug, 41 | bin_args: None, 42 | .. 43 | }, 44 | style: StyleConfig { 45 | file: Some( 46 | SourcedSiteFile { 47 | source: "project2/src/main.scss", 48 | dest: "target/site/project2/pkg/project2.css", 49 | site: "pkg/project2.css", 50 | }, 51 | ), 52 | browserquery: "defaults", 53 | tailwind: None, 54 | site_file: SiteFile { 55 | dest: "target/site/project2/pkg/project2.css", 56 | site: "pkg/project2.css", 57 | }, 58 | }, 59 | watch: true, 60 | release: false, 61 | precompress: false, 62 | js_minify: false, 63 | hot_reload: false, 64 | site: Site { 65 | addr: 127.0.0.1:3000, 66 | reload: 127.0.0.1:3001, 67 | root_dir: "target/site/project2", 68 | pkg_dir: "pkg", 69 | file_reg: {}, 70 | ext_file_reg: {}, 71 | }, 72 | end2end: None, 73 | assets: Some( 74 | AssetsConfig { 75 | dir: "project2/src/assets", 76 | }, 77 | ), 78 | server_fn_prefix: None, 79 | disable_server_fn_hash: false, 80 | disable_erase_components: false, 81 | always_erase_components: false, 82 | server_fn_mod_path: false, 83 | .. 84 | }, 85 | ], 86 | cli: Opts { 87 | release: false, 88 | precompress: false, 89 | hot_reload: false, 90 | project: Some( 91 | "project2", 92 | ), 93 | features: [], 94 | lib_features: [], 95 | lib_cargo_args: None, 96 | bin_features: [], 97 | bin_cargo_args: None, 98 | wasm_debug: false, 99 | verbose: 0, 100 | js_minify: false, 101 | }, 102 | watch: true, 103 | .. 104 | } 105 | -------------------------------------------------------------------------------- /src/config/style.rs: -------------------------------------------------------------------------------- 1 | use super::{ProjectConfig, TailwindConfig}; 2 | use crate::{ 3 | ext::eyre::reexports::Result, 4 | service::site::{SiteFile, SourcedSiteFile}, 5 | }; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct StyleConfig { 9 | pub file: Option<SourcedSiteFile>, 10 | pub browserquery: String, 11 | pub tailwind: Option<TailwindConfig>, 12 | pub site_file: SiteFile, 13 | } 14 | 15 | impl StyleConfig { 16 | pub fn new(config: &ProjectConfig) -> Result<Self> { 17 | let site_rel = config 18 | .site_pkg_dir 19 | .join(&config.output_name) 20 | .with_extension("css"); 21 | 22 | let site_file = SiteFile { 23 | dest: config.site_root.join(&site_rel), 24 | site: site_rel, 25 | }; 26 | let style_file = config.style_file.as_ref().map(|file| { 27 | // relative to the configuration file 28 | let source = config.config_dir.join(file); 29 | let site = config 30 | .site_pkg_dir 31 | .join(&config.output_name) 32 | .with_extension("css"); 33 | let dest = config.site_root.join(&site); 34 | SourcedSiteFile { source, dest, site } 35 | }); 36 | Ok(Self { 37 | file: style_file, 38 | browserquery: config.browserquery.clone(), 39 | tailwind: TailwindConfig::new(config)?, 40 | site_file, 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/config/tailwind.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | 3 | use super::{ProjectConfig, VersionConfig}; 4 | use crate::internal_prelude::*; 5 | 6 | #[derive(Clone, Debug)] 7 | pub struct TailwindConfig { 8 | pub input_file: Utf8PathBuf, 9 | pub config_file: Option<Utf8PathBuf>, 10 | pub tmp_file: Utf8PathBuf, 11 | } 12 | 13 | impl TailwindConfig { 14 | pub fn new(conf: &ProjectConfig) -> Result<Option<Self>> { 15 | let input_file = if let Some(input_file) = conf.tailwind_input_file.clone() { 16 | conf.config_dir.join(input_file) 17 | } else { 18 | if conf.tailwind_config_file.is_some() { 19 | bail!("The Cargo.toml `tailwind-input-file` is required when using `tailwind-config-file`]"); 20 | } 21 | return Ok(None); 22 | }; 23 | 24 | let is_v_4 = VersionConfig::Tailwind.version().starts_with("v4"); 25 | 26 | let config_file = if is_v_4 { 27 | if conf.tailwind_config_file.is_some() 28 | || conf.config_dir.join("tailwind.config.js").exists() 29 | { 30 | info!("JavaScript config files are no longer required in Tailwind CSS v4. If you still need to use a JS config file, refer to the docs here: https://tailwindcss.com/docs/upgrade-guide#using-a-javascript-config-file."); 31 | } 32 | 33 | conf.tailwind_config_file.clone() 34 | } else { 35 | Some( 36 | conf.config_dir.join( 37 | conf.tailwind_config_file 38 | .clone() 39 | .unwrap_or_else(|| Utf8PathBuf::from("tailwind.config.js")), 40 | ), 41 | ) 42 | }; 43 | 44 | let tmp_file = conf.tmp_dir.join("tailwind.css"); 45 | 46 | Ok(Some(Self { 47 | input_file, 48 | config_file, 49 | tmp_file, 50 | })) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/config/tests.rs: -------------------------------------------------------------------------------- 1 | use super::Config; 2 | 3 | fn opts(project: Option<&str>) -> crate::config::Opts { 4 | crate::config::Opts { 5 | release: false, 6 | js_minify: false, 7 | precompress: false, 8 | hot_reload: false, 9 | project: project.map(|s| s.to_string()), 10 | verbose: 0, 11 | features: Vec::new(), 12 | bin_features: Vec::new(), 13 | lib_features: Vec::new(), 14 | bin_cargo_args: None, 15 | lib_cargo_args: None, 16 | wasm_debug: false, 17 | } 18 | } 19 | 20 | // this test causes issues in CI because the tailwind tmp_file field is an absolute path, 21 | // so differs by platform 22 | /* #[test] 23 | fn test_project() { 24 | let cli = opts(None); 25 | 26 | let conf = Config::test_load(cli, "examples", "examples/project/Cargo.toml", true); 27 | 28 | insta::assert_debug_snapshot!(conf); 29 | } */ 30 | 31 | #[test] 32 | fn test_workspace() { 33 | let cli = opts(None); 34 | 35 | let conf = Config::test_load(cli, "examples", "examples/workspace/Cargo.toml", true, None); 36 | 37 | insta::assert_debug_snapshot!(conf); 38 | } 39 | 40 | #[test] 41 | fn test_workspace_project1() { 42 | let cli = opts(Some("project1")); 43 | 44 | let conf = Config::test_load(cli, "examples", "examples/workspace/Cargo.toml", true, None); 45 | 46 | insta::assert_debug_snapshot!(conf); 47 | } 48 | 49 | #[test] 50 | fn test_workspace_project2() { 51 | let cli = opts(Some("project2")); 52 | 53 | let conf = Config::test_load(cli, "examples", "examples/workspace/Cargo.toml", true, None); 54 | 55 | insta::assert_debug_snapshot!(conf); 56 | } 57 | 58 | #[test] 59 | fn test_workspace_in_subdir_project2() { 60 | let cli = opts(None); 61 | 62 | let conf = Config::test_load( 63 | cli, 64 | "examples/workspace/project2", 65 | "examples/workspace/Cargo.toml", 66 | true, 67 | None, 68 | ); 69 | 70 | insta::assert_debug_snapshot!(conf); 71 | } 72 | 73 | #[test] 74 | fn test_workspace_bin_args_project2() { 75 | let cli = opts(Some("project2")); 76 | 77 | let conf = Config::test_load( 78 | cli, 79 | "examples", 80 | "examples/workspace/Cargo.toml", 81 | true, 82 | Some(&["--".to_string(), "--foo".to_string()]), 83 | ); 84 | 85 | insta::assert_debug_snapshot!(conf); 86 | } 87 | -------------------------------------------------------------------------------- /src/config/version.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, env}; 2 | 3 | pub const ENV_VAR_LEPTOS_TAILWIND_VERSION: &str = "LEPTOS_TAILWIND_VERSION"; 4 | pub const ENV_VAR_LEPTOS_SASS_VERSION: &str = "LEPTOS_SASS_VERSION"; 5 | 6 | pub enum VersionConfig { 7 | Tailwind, 8 | Sass, 9 | } 10 | 11 | impl VersionConfig { 12 | pub fn version<'a>(&self) -> Cow<'a, str> { 13 | env::var(self.env_var_version_name()) 14 | .map(Cow::Owned) 15 | .unwrap_or_else(|_| self.default_version().into()) 16 | } 17 | 18 | pub fn default_version(&self) -> &'static str { 19 | match self { 20 | Self::Tailwind => "v4.1.4", 21 | Self::Sass => "1.86.0", 22 | } 23 | } 24 | 25 | pub fn env_var_version_name(&self) -> &'static str { 26 | match self { 27 | Self::Tailwind => ENV_VAR_LEPTOS_TAILWIND_VERSION, 28 | Self::Sass => ENV_VAR_LEPTOS_SASS_VERSION, 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ext/cargo.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use super::{PathBufExt, PathExt}; 4 | use crate::internal_prelude::*; 5 | use camino::{Utf8Path, Utf8PathBuf}; 6 | use cargo_metadata::{CrateType, Metadata, MetadataCommand, Package, PackageId, Resolve, Target}; 7 | 8 | pub trait PackageExt { 9 | fn has_bin_target(&self) -> bool; 10 | fn bin_targets(&self) -> Box<dyn Iterator<Item = &Target> + '_>; 11 | fn cdylib_target(&self) -> Option<&Target>; 12 | fn target_list(&self) -> String; 13 | fn path_dependencies(&self) -> Vec<Utf8PathBuf>; 14 | } 15 | 16 | impl PackageExt for Package { 17 | fn has_bin_target(&self) -> bool { 18 | self.targets.iter().any(|t| t.is_bin()) 19 | } 20 | 21 | fn bin_targets(&self) -> Box<dyn Iterator<Item = &Target> + '_> { 22 | Box::new(self.targets.iter().filter(|t| t.is_bin())) 23 | } 24 | fn cdylib_target(&self) -> Option<&Target> { 25 | self.targets 26 | .iter() 27 | .find(|t| t.crate_types.contains(&CrateType::CDyLib)) 28 | } 29 | fn target_list(&self) -> String { 30 | self.targets 31 | .iter() 32 | .map(|t| { 33 | format!( 34 | "{} ({})", 35 | t.name, 36 | t.crate_types 37 | .iter() 38 | .map(|c| c.to_string()) 39 | .collect::<Vec<_>>() 40 | .join(", ") 41 | ) 42 | }) 43 | .collect::<Vec<_>>() 44 | .join(", ") 45 | } 46 | 47 | fn path_dependencies(&self) -> Vec<Utf8PathBuf> { 48 | let mut found = Vec::new(); 49 | for dep in &self.dependencies { 50 | if let Some(path) = &dep.path { 51 | found.push(path.clone()); 52 | } 53 | } 54 | found 55 | } 56 | } 57 | 58 | pub trait MetadataExt { 59 | fn load_cleaned(manifest_path: &Utf8Path) -> Result<Metadata>; 60 | fn rel_target_dir(&self) -> Utf8PathBuf; 61 | fn package_for(&self, id: &PackageId) -> Option<&Package>; 62 | fn path_dependencies(&self, id: &PackageId) -> Vec<Utf8PathBuf>; 63 | fn src_path_dependencies(&self, id: &PackageId) -> Vec<Utf8PathBuf>; 64 | } 65 | 66 | impl MetadataExt for Metadata { 67 | fn load_cleaned(manifest_path: &Utf8Path) -> Result<Metadata> { 68 | let mut metadata = MetadataCommand::new().manifest_path(manifest_path).exec()?; 69 | metadata.workspace_root.clean_windows_path(); 70 | metadata.target_directory.clean_windows_path(); 71 | for package in &mut metadata.packages { 72 | package.manifest_path.clean_windows_path(); 73 | for dependency in &mut package.dependencies { 74 | if let Some(p) = dependency.path.as_mut() { 75 | p.clean_windows_path() 76 | } 77 | } 78 | } 79 | Ok(metadata) 80 | } 81 | 82 | fn rel_target_dir(&self) -> Utf8PathBuf { 83 | pathdiff::diff_utf8_paths(&self.target_directory, &self.workspace_root).unwrap() 84 | } 85 | 86 | fn package_for(&self, id: &PackageId) -> Option<&Package> { 87 | self.packages.iter().find(|p| p.id == *id) 88 | } 89 | 90 | fn path_dependencies(&self, id: &PackageId) -> Vec<Utf8PathBuf> { 91 | let Some(resolve) = &self.resolve else { 92 | return vec![]; 93 | }; 94 | let mut found = vec![]; 95 | 96 | let mut set = HashSet::new(); 97 | resolve.deps_for(id, &mut set); 98 | 99 | for pck in &self.packages { 100 | if set.contains(&pck.id) { 101 | found.extend(pck.path_dependencies()) 102 | } 103 | } 104 | 105 | found 106 | } 107 | 108 | fn src_path_dependencies(&self, id: &PackageId) -> Vec<Utf8PathBuf> { 109 | let root = &self.workspace_root; 110 | self.path_dependencies(id) 111 | .iter() 112 | .map(|p| { 113 | let path = p.unbase(root).unwrap_or_else(|_| p.to_path_buf()); 114 | 115 | if path == "." { 116 | Utf8PathBuf::from("src") 117 | } else { 118 | path.join("src") 119 | } 120 | }) 121 | .collect() 122 | } 123 | } 124 | 125 | pub trait ResolveExt { 126 | fn deps_for(&self, id: &PackageId, set: &mut HashSet<PackageId>); 127 | } 128 | 129 | impl ResolveExt for Resolve { 130 | fn deps_for(&self, id: &PackageId, set: &mut HashSet<PackageId>) { 131 | if let Some(node) = self.nodes.iter().find(|n| n.id == *id) { 132 | if set.insert(node.id.clone()) { 133 | for dep in &node.deps { 134 | self.deps_for(&dep.pkg, set); 135 | } 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ext/compress.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use brotli::enc::BrotliEncoderParams; 3 | use libflate::gzip; 4 | use std::fs; 5 | use std::fs::File; 6 | use std::io::{BufReader, Write}; 7 | use std::path::PathBuf; 8 | use tokio::time::Instant; 9 | 10 | pub async fn compress_static_files(path: PathBuf) -> Result<()> { 11 | let start = Instant::now(); 12 | 13 | tokio::task::spawn_blocking(move || compress_dir_all(path)).await??; 14 | 15 | info!( 16 | "Precompression of static files finished after {} ms", 17 | start.elapsed().as_millis() 18 | ); 19 | Ok(()) 20 | } 21 | 22 | // This is sync / blocking because an async / parallel execution did provide only a small benefit 23 | // in performance (~4%) while needing quite a few more dependencies and much more verbose code. 24 | fn compress_dir_all(path: PathBuf) -> Result<()> { 25 | trace!("FS compress_dir_all {:?}", path); 26 | 27 | let dir = fs::read_dir(&path).wrap_err(format!("Could not read {:?}", path))?; 28 | let brotli_params = BrotliEncoderParams::default(); 29 | 30 | for entry in dir.into_iter() { 31 | let path = entry?.path(); 32 | let metadata = fs::metadata(&path)?; 33 | 34 | if metadata.is_dir() { 35 | compress_dir_all(path)?; 36 | } else { 37 | let pstr = path.to_str().unwrap_or_default(); 38 | if pstr.ends_with(".gz") || pstr.ends_with(".br") { 39 | // skip all files that are already compressed 40 | continue; 41 | } 42 | 43 | let file = fs::read(&path)?; 44 | 45 | // gzip 46 | let mut encoder = gzip::Encoder::new(Vec::new())?; 47 | encoder.write_all(file.as_ref())?; 48 | let encoded_data = encoder.finish().into_result()?; 49 | let path_gz = format!("{}.gz", pstr); 50 | fs::write(path_gz, encoded_data)?; 51 | 52 | // brotli 53 | let path_br = format!("{}.br", pstr); 54 | let mut output = File::create(path_br)?; 55 | let mut reader = BufReader::new(file.as_slice()); 56 | brotli::BrotliCompress(&mut reader, &mut output, &brotli_params)?; 57 | } 58 | } 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/ext/eyre.rs: -------------------------------------------------------------------------------- 1 | use core::convert::Infallible; 2 | use std::{fmt::Display, panic::Location}; 3 | 4 | pub(crate) mod reexports { 5 | //! re-exports 6 | 7 | pub use super::{AnyhowCompatWrapErr as _, CustomWrapErr as _}; 8 | pub use color_eyre::eyre::{self, bail, ensure, eyre, Report as Error, Result}; 9 | } 10 | use reexports::*; 11 | 12 | pub trait CustomWrapErr<T, E> { 13 | fn wrap_err<C>(self, context: C) -> Result<T> 14 | where 15 | C: Display + Send + Sync + 'static; 16 | 17 | fn wrap_err_with<C, F>(self, context: F) -> Result<T> 18 | where 19 | C: Display + Send + Sync + 'static, 20 | F: FnOnce() -> C; 21 | 22 | /// like google map red dot, only record the location info without any context message. 23 | fn dot(self) -> Result<T>; 24 | } 25 | 26 | /// For some reason, `anyhow::Error` doesn't impl `std::error::Error`??! 27 | /// Why! Your an error handling library! Anyhow, this increases ergonomic 28 | /// to work around this limitation 29 | pub trait AnyhowCompatWrapErr<T> { 30 | fn wrap_anyhow_err<C>(self, context: C) -> Result<T> 31 | where 32 | C: Display + Send + Sync + 'static; 33 | 34 | fn wrap_anyhow_err_with<C, F>(self, context: F) -> Result<T> 35 | where 36 | C: Display + Send + Sync + 'static, 37 | F: FnOnce() -> C; 38 | 39 | /// like google map red dot, only record the location info without any context message. 40 | fn dot_anyhow(self) -> Result<T>; 41 | } 42 | 43 | /// https://github.com/dtolnay/anyhow/issues/356#issuecomment-2053956844 44 | struct AnyhowNewType(anyhow::Error); 45 | 46 | impl std::fmt::Debug for AnyhowNewType { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | write!(f, "{:?}", &self.0) 49 | } 50 | } 51 | impl std::fmt::Display for AnyhowNewType { 52 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 53 | anyhow::Error::fmt(&self.0, f) 54 | } 55 | } 56 | impl core::error::Error for AnyhowNewType {} 57 | 58 | impl<T> AnyhowCompatWrapErr<T> for anyhow::Result<T> { 59 | #[inline] 60 | #[track_caller] 61 | fn wrap_anyhow_err<C>(self, context: C) -> Result<T> 62 | where 63 | C: Display + Send + Sync + 'static, 64 | { 65 | let caller = Location::caller(); 66 | eyre::WrapErr::wrap_err( 67 | self.map_err(AnyhowNewType), 68 | format!( 69 | "{} at `{}:{}:{}`", 70 | context, 71 | caller.file(), 72 | caller.line(), 73 | caller.column() 74 | ), 75 | ) 76 | } 77 | 78 | #[inline] 79 | #[track_caller] 80 | fn wrap_anyhow_err_with<C, F>(self, context: F) -> Result<T> 81 | where 82 | C: Display + Send + Sync + 'static, 83 | F: FnOnce() -> C, 84 | { 85 | let caller = Location::caller(); 86 | eyre::WrapErr::wrap_err_with(self.map_err(AnyhowNewType), || { 87 | format!( 88 | "{} at `{}:{}:{}`", 89 | context(), 90 | caller.file(), 91 | caller.line(), 92 | caller.column(), 93 | ) 94 | }) 95 | } 96 | 97 | #[inline] 98 | #[track_caller] 99 | fn dot_anyhow(self) -> Result<T> { 100 | let caller = Location::caller(); 101 | eyre::WrapErr::wrap_err( 102 | self.map_err(AnyhowNewType), 103 | format!( 104 | "at `{}:{}:{}`", 105 | caller.file(), 106 | caller.line(), 107 | caller.column() 108 | ), 109 | ) 110 | } 111 | } 112 | 113 | impl<T, E> CustomWrapErr<T, E> for Result<T, E> 114 | where 115 | E: Display, 116 | Result<T, E>: eyre::WrapErr<T, E>, 117 | { 118 | #[inline] 119 | #[track_caller] 120 | fn wrap_err<C>(self, context: C) -> Result<T> 121 | where 122 | C: Display + Send + Sync + 'static, 123 | { 124 | let caller = Location::caller(); 125 | eyre::WrapErr::wrap_err( 126 | self, 127 | format!( 128 | "{} at `{}:{}:{}`", 129 | context, 130 | caller.file(), 131 | caller.line(), 132 | caller.column() 133 | ), 134 | ) 135 | } 136 | 137 | #[inline] 138 | #[track_caller] 139 | fn wrap_err_with<C, F>(self, context: F) -> Result<T> 140 | where 141 | C: Display + Send + Sync + 'static, 142 | F: FnOnce() -> C, 143 | { 144 | let caller = Location::caller(); 145 | eyre::WrapErr::wrap_err_with(self, || { 146 | format!( 147 | "{} at `{}:{}:{}`", 148 | context(), 149 | caller.file(), 150 | caller.line(), 151 | caller.column(), 152 | ) 153 | }) 154 | } 155 | 156 | #[inline] 157 | #[track_caller] 158 | fn dot(self) -> Result<T> { 159 | let caller = Location::caller(); 160 | eyre::WrapErr::wrap_err( 161 | self, 162 | format!( 163 | "at `{}:{}:{}`", 164 | caller.file(), 165 | caller.line(), 166 | caller.column() 167 | ), 168 | ) 169 | } 170 | } 171 | 172 | impl<T> CustomWrapErr<T, Infallible> for Option<T> 173 | where 174 | Option<T>: eyre::WrapErr<T, Infallible>, 175 | { 176 | #[inline] 177 | #[track_caller] 178 | fn wrap_err<C>(self, context: C) -> Result<T, Error> 179 | where 180 | C: Display + Send + Sync + 'static, 181 | { 182 | let caller = Location::caller(); 183 | eyre::WrapErr::wrap_err( 184 | self, 185 | format!( 186 | "{} at `{}:{}:{}`", 187 | context, 188 | caller.file(), 189 | caller.line(), 190 | caller.column() 191 | ), 192 | ) 193 | } 194 | 195 | #[inline] 196 | #[track_caller] 197 | fn wrap_err_with<C, F>(self, context: F) -> Result<T, Error> 198 | where 199 | C: Display + Send + Sync + 'static, 200 | F: FnOnce() -> C, 201 | { 202 | let caller = Location::caller(); 203 | eyre::WrapErr::wrap_err_with(self, || { 204 | format!( 205 | "{} at `{}:{}:{}`", 206 | context(), 207 | caller.file(), 208 | caller.line(), 209 | caller.column(), 210 | ) 211 | }) 212 | } 213 | 214 | #[inline] 215 | #[track_caller] 216 | fn dot(self) -> Result<T> { 217 | let caller = Location::caller(); 218 | eyre::WrapErr::wrap_err( 219 | self, 220 | format!( 221 | "at `{}:{}:{}`", 222 | caller.file(), 223 | caller.line(), 224 | caller.column() 225 | ), 226 | ) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/ext/fs.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use camino::{Utf8Path, Utf8PathBuf}; 3 | use std::{collections::VecDeque, path::Path}; 4 | use tokio::fs::{self, ReadDir}; 5 | 6 | use super::path::PathExt; 7 | 8 | pub async fn rm_dir_content<P: AsRef<Path>>(dir: P) -> Result<()> { 9 | try_rm_dir_content(&dir) 10 | .await 11 | .wrap_err(format!("Could not remove contents of {:?}", dir.as_ref())) 12 | } 13 | 14 | async fn try_rm_dir_content<P: AsRef<Path>>(dir: P) -> Result<()> { 15 | let dir = dir.as_ref(); 16 | 17 | if !dir.exists() { 18 | debug!("Leptos not cleaning {dir:?} because it does not exist"); 19 | return Ok(()); 20 | } 21 | 22 | let mut entries = self::read_dir(dir).await?; 23 | while let Some(entry) = entries.next_entry().await? { 24 | let path = entry.path(); 25 | 26 | if entry.file_type().await?.is_dir() { 27 | self::remove_dir_all(path).await?; 28 | } else { 29 | self::remove_file(path).await?; 30 | } 31 | } 32 | Ok(()) 33 | } 34 | 35 | pub async fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> { 36 | fs::write(&path, contents) 37 | .await 38 | .wrap_err(format!("Could not write to {:?}", path.as_ref())) 39 | } 40 | 41 | pub async fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> { 42 | fs::read(&path) 43 | .await 44 | .wrap_err(format!("Could not read {:?}", path.as_ref())) 45 | } 46 | 47 | pub async fn create_dir(path: impl AsRef<Path>) -> Result<()> { 48 | trace!("FS create_dir {:?}", path.as_ref()); 49 | fs::create_dir(&path) 50 | .await 51 | .wrap_err(format!("Could not create dir {:?}", path.as_ref())) 52 | } 53 | 54 | pub async fn create_dir_all<P: AsRef<Path>>(path: P) -> Result<()> { 55 | trace!("FS create_dir_all {:?}", path.as_ref()); 56 | fs::create_dir_all(&path) 57 | .await 58 | .wrap_err(format!("Could not create {:?}", path.as_ref())) 59 | } 60 | pub async fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String> { 61 | fs::read_to_string(&path) 62 | .await 63 | .wrap_err(format!("Could not read to string {:?}", path.as_ref())) 64 | } 65 | 66 | pub async fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<u64> { 67 | fs::copy(&from, &to) 68 | .await 69 | .wrap_err(format!("copy {:?} to {:?}", from.as_ref(), to.as_ref())) 70 | } 71 | 72 | pub async fn read_dir<P: AsRef<Path>>(path: P) -> Result<ReadDir> { 73 | fs::read_dir(&path) 74 | .await 75 | .wrap_err(format!("Could not read dir {:?}", path.as_ref())) 76 | } 77 | 78 | pub async fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> { 79 | fs::rename(&from, &to).await.wrap_err(format!( 80 | "Could not rename from {:?} to {:?}", 81 | from.as_ref(), 82 | to.as_ref() 83 | )) 84 | } 85 | 86 | pub async fn remove_file<P: AsRef<Path>>(path: P) -> Result<()> { 87 | fs::remove_file(&path) 88 | .await 89 | .wrap_err(format!("Could not remove file {:?}", path.as_ref())) 90 | } 91 | 92 | #[allow(dead_code)] 93 | pub async fn remove_dir<P: AsRef<Path>>(path: P) -> Result<()> { 94 | fs::remove_dir(&path) 95 | .await 96 | .wrap_err(format!("Could not remove dir {:?}", path.as_ref())) 97 | } 98 | 99 | pub async fn remove_dir_all<P: AsRef<Path>>(path: P) -> Result<()> { 100 | fs::remove_dir_all(&path) 101 | .await 102 | .wrap_err(format!("Could not remove dir {:?}", path.as_ref())) 103 | } 104 | 105 | pub async fn copy_dir_all(src: impl AsRef<Utf8Path>, dst: impl AsRef<Path>) -> Result<()> { 106 | cp_dir_all(&src, &dst).await.wrap_err(format!( 107 | "Copy dir recursively from {:?} to {:?}", 108 | src.as_ref(), 109 | dst.as_ref() 110 | )) 111 | } 112 | 113 | async fn cp_dir_all(src: impl AsRef<Utf8Path>, dst: impl AsRef<Path>) -> Result<()> { 114 | let src = src.as_ref(); 115 | let dst = Utf8PathBuf::from_path_buf(dst.as_ref().to_path_buf()).unwrap(); 116 | 117 | self::create_dir_all(&dst).await?; 118 | 119 | let mut dirs = VecDeque::new(); 120 | dirs.push_back(src.to_owned()); 121 | 122 | while let Some(dir) = dirs.pop_front() { 123 | let mut entries = dir.read_dir_utf8()?; 124 | 125 | while let Some(Ok(entry)) = entries.next() { 126 | let from = entry.path().to_owned(); 127 | let to = from.rebase(src, &dst)?; 128 | 129 | if entry.file_type()?.is_dir() { 130 | self::create_dir(&to).await?; 131 | dirs.push_back(from); 132 | } else { 133 | self::copy(from, to).await?; 134 | } 135 | } 136 | } 137 | Ok(()) 138 | } 139 | -------------------------------------------------------------------------------- /src/ext/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(test, feature = "full_tests"))] 2 | mod tests; 3 | 4 | mod cargo; 5 | pub mod compress; 6 | pub mod exe; 7 | pub mod eyre; 8 | pub mod fs; 9 | mod path; 10 | pub mod sync; 11 | mod util; 12 | 13 | pub use cargo::{MetadataExt, PackageExt}; 14 | pub use exe::{Exe, ExeMeta}; 15 | pub use path::{ 16 | append_str_to_filename, determine_pdb_filename, remove_nested, PathBufExt, PathExt, 17 | }; 18 | pub use util::{os_arch, Paint, StrAdditions}; 19 | -------------------------------------------------------------------------------- /src/ext/sync.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use std::{ 3 | net::SocketAddr, 4 | process::{Output, Stdio}, 5 | time::Duration, 6 | }; 7 | use tokio::{ 8 | net::TcpStream, 9 | process::{Child, Command}, 10 | sync::broadcast, 11 | time::sleep, 12 | }; 13 | 14 | pub trait OutputExt { 15 | fn stderr(&self) -> String; 16 | fn has_stderr(&self) -> bool; 17 | fn stdout(&self) -> String; 18 | fn has_stdout(&self) -> bool; 19 | } 20 | 21 | impl OutputExt for Output { 22 | fn stderr(&self) -> String { 23 | String::from_utf8_lossy(&self.stderr).to_string() 24 | } 25 | 26 | fn has_stderr(&self) -> bool { 27 | println!("stderr: {}\n'{}'", self.stderr.len(), self.stderr()); 28 | self.stderr.len() > 1 29 | } 30 | 31 | fn stdout(&self) -> String { 32 | String::from_utf8_lossy(&self.stdout).to_string() 33 | } 34 | 35 | fn has_stdout(&self) -> bool { 36 | self.stdout.len() > 1 37 | } 38 | } 39 | pub enum CommandResult<T> { 40 | Success(T), 41 | Failure(T), 42 | Interrupted, 43 | } 44 | 45 | pub async fn wait_interruptible( 46 | name: &str, 47 | mut process: Child, 48 | mut interrupt_rx: broadcast::Receiver<()>, 49 | ) -> Result<CommandResult<()>> { 50 | trace!(?process, "Waiting for process to finish"); 51 | tokio::select! { 52 | res = process.wait() => match res { 53 | Ok(exit) => { 54 | if exit.success() { 55 | trace!("{name} process finished with success"); 56 | Ok(CommandResult::Success(())) 57 | } else { 58 | trace!("{name} process finished with code {:?}", exit.code()); 59 | Ok(CommandResult::Failure(())) 60 | } 61 | } 62 | Err(e) => bail!("Command failed due to: {e}"), 63 | }, 64 | _ = interrupt_rx.recv() => { 65 | process.kill().await.wrap_err("Could not kill process")?; 66 | trace!("{name} process interrupted"); 67 | Ok(CommandResult::Interrupted) 68 | } 69 | } 70 | } 71 | 72 | pub async fn wait_piped_interruptible( 73 | name: &str, 74 | mut cmd: Command, 75 | mut interrupt_rx: broadcast::Receiver<()>, 76 | ) -> Result<CommandResult<Output>> { 77 | trace!(?cmd, "Waiting for command (piped)"); 78 | // see: https://docs.rs/tokio/latest/tokio/process/index.html 79 | 80 | cmd.kill_on_drop(true); 81 | cmd.stdout(Stdio::piped()); 82 | cmd.stderr(Stdio::piped()); 83 | let process = cmd.spawn()?; 84 | tokio::select! { 85 | res = process.wait_with_output() => match res { 86 | Ok(output) => { 87 | if output.status.success() { 88 | trace!("{name} process finished with success"); 89 | Ok(CommandResult::Success(output)) 90 | } else { 91 | trace!("{name} process finished with code {:?}", output.status.code()); 92 | Ok(CommandResult::Failure(output)) 93 | } 94 | } 95 | Err(e) => bail!("Command failed due to: {e}"), 96 | }, 97 | _ = interrupt_rx.recv() => { 98 | trace!("{name} process interrupted"); 99 | Ok(CommandResult::Interrupted) 100 | } 101 | } 102 | } 103 | pub async fn wait_for_socket(name: &str, addr: SocketAddr) -> bool { 104 | let duration = Duration::from_millis(500); 105 | 106 | for _ in 0..20 { 107 | if TcpStream::connect(&addr).await.is_ok() { 108 | debug!("{name} server port {addr} open"); 109 | return true; 110 | } 111 | sleep(duration).await; 112 | } 113 | warn!("{name} timed out waiting for port {addr}"); 114 | false 115 | } 116 | -------------------------------------------------------------------------------- /src/ext/tests.rs: -------------------------------------------------------------------------------- 1 | use super::exe::Exe; 2 | use crate::ext::path::PathBufExt; 3 | use camino::Utf8PathBuf; 4 | use temp_dir::TempDir; 5 | 6 | #[tokio::test] 7 | async fn download_sass() { 8 | let dir = TempDir::new().unwrap(); 9 | let meta = Exe::Sass.meta().await.unwrap(); 10 | let e = meta.with_cache_dir(dir.path()).await; 11 | 12 | assert!(e.is_ok(), "{e:#?}\n{:#?}\nFiles: \n {}", meta, ls(&dir)); 13 | 14 | let e = e.unwrap(); 15 | assert!(e.exists(), "{:#?}\nFiles: \n{}", meta, ls(&dir)); 16 | } 17 | 18 | #[tokio::test] 19 | async fn download_tailwind() { 20 | let dir = TempDir::new().unwrap(); 21 | let meta = Exe::Tailwind.meta().await.unwrap(); 22 | let e = meta.with_cache_dir(dir.path()).await; 23 | assert!(e.is_ok(), "{e:#?}\n{:#?}\nFiles: \n {}", meta, ls(&dir)); 24 | 25 | let e = e.unwrap(); 26 | assert!(e.exists(), "{:#?}\nFiles: \n{}", meta, ls(&dir)) 27 | } 28 | 29 | fn ls(dir: &TempDir) -> String { 30 | Utf8PathBuf::from_path_buf(dir.path().to_path_buf()) 31 | .unwrap() 32 | .ls_ascii(0) 33 | .unwrap_or_default() 34 | } 35 | -------------------------------------------------------------------------------- /src/ext/util.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use camino::Utf8PathBuf; 3 | use clap::builder::styling::{Color, Style}; 4 | use std::borrow::Cow; 5 | 6 | pub fn os_arch() -> Result<(&'static str, &'static str)> { 7 | let target_os = if cfg!(target_os = "windows") { 8 | "windows" 9 | } else if cfg!(target_os = "macos") { 10 | "macos" 11 | } else if cfg!(target_os = "linux") { 12 | "linux" 13 | } else { 14 | bail!("unsupported OS") 15 | }; 16 | 17 | let target_arch = if cfg!(target_arch = "x86_64") { 18 | "x86_64" 19 | } else if cfg!(target_arch = "aarch64") { 20 | "aarch64" 21 | } else { 22 | bail!("unsupported target architecture") 23 | }; 24 | Ok((target_os, target_arch)) 25 | } 26 | 27 | pub fn is_linux_musl_env() -> bool { 28 | cfg!(target_os = "linux") && cfg!(target_env = "musl") 29 | } 30 | 31 | pub trait StrAdditions { 32 | fn with(&self, append: &str) -> String; 33 | fn pad_left_to(&self, len: usize) -> Cow<str>; 34 | /// returns the string as a canonical path (creates the dir if necessary) 35 | fn to_created_dir(&self) -> Result<Utf8PathBuf>; 36 | } 37 | 38 | impl StrAdditions for str { 39 | fn with(&self, append: &str) -> String { 40 | let mut s = self.to_string(); 41 | s.push_str(append); 42 | s 43 | } 44 | 45 | fn pad_left_to(&self, len: usize) -> Cow<str> { 46 | let chars = self.chars().count(); 47 | if chars < len { 48 | Cow::Owned(format!("{}{self}", " ".repeat(len - chars))) 49 | } else { 50 | Cow::Borrowed(self) 51 | } 52 | } 53 | 54 | fn to_created_dir(&self) -> Result<Utf8PathBuf> { 55 | let path = Utf8PathBuf::from(self); 56 | if !path.exists() { 57 | std::fs::create_dir_all(&path).wrap_err(format!("Could not create dir {self:?}"))?; 58 | } 59 | Ok(path) 60 | } 61 | } 62 | 63 | impl StrAdditions for String { 64 | fn with(&self, append: &str) -> String { 65 | let mut s = self.clone(); 66 | s.push_str(append); 67 | s 68 | } 69 | 70 | fn pad_left_to(&self, len: usize) -> Cow<str> { 71 | self.as_str().pad_left_to(len) 72 | } 73 | 74 | fn to_created_dir(&self) -> Result<Utf8PathBuf> { 75 | self.as_str().to_created_dir() 76 | } 77 | } 78 | 79 | pub trait Paint { 80 | fn paint<'a>(self, text: impl Into<Cow<'a, str>>) -> String; 81 | } 82 | 83 | impl Paint for Color { 84 | fn paint<'a>(self, text: impl Into<Cow<'a, str>>) -> String { 85 | let text = text.into(); 86 | let style = Style::new().fg_color(Some(self)); 87 | 88 | format!("{style}{text}{style:#}") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(test, feature = "full_tests"))] 2 | mod tests; 3 | 4 | mod command; 5 | pub mod compile; 6 | pub mod config; 7 | pub mod ext; 8 | pub mod logger; 9 | pub mod service; 10 | pub mod signal; 11 | mod internal_prelude { 12 | pub use crate::ext::{eyre::reexports::*, Paint as _}; 13 | pub use tracing::*; 14 | } 15 | 16 | use crate::{config::Commands, ext::PathBufExt, logger::GRAY}; 17 | use camino::Utf8PathBuf; 18 | use config::{Cli, Config}; 19 | use ext::{fs, Paint}; 20 | use signal::Interrupt; 21 | use std::{env, path::PathBuf}; 22 | 23 | use crate::internal_prelude::*; 24 | 25 | pub async fn run(args: Cli) -> Result<()> { 26 | if let New(new) = args.command { 27 | return new.run(); 28 | } 29 | 30 | let manifest_path = args 31 | .manifest_path 32 | .to_owned() 33 | .unwrap_or_else(|| Utf8PathBuf::from("Cargo.toml")) 34 | .resolve_home_dir() 35 | .wrap_err(format!("manifest_path: {:?}", &args.manifest_path))?; 36 | let mut cwd = Utf8PathBuf::from_path_buf(env::current_dir().unwrap()).unwrap(); 37 | cwd.clean_windows_path(); 38 | 39 | let opts = args.opts().unwrap(); 40 | let bin_args = args.bin_args(); 41 | 42 | let watch = matches!(args.command, Commands::Watch(_)); 43 | let config = Config::load(opts, &cwd, &manifest_path, watch, bin_args).dot()?; 44 | env::set_current_dir(&config.working_dir).dot()?; 45 | debug!( 46 | "Path working dir {}", 47 | GRAY.paint(config.working_dir.as_str()) 48 | ); 49 | 50 | if config.working_dir.join("package.json").exists() { 51 | debug!("Path found 'package.json' adding 'node_modules/.bin' to PATH"); 52 | let node_modules = &config.working_dir.join("node_modules"); 53 | if node_modules.exists() { 54 | match env::var("PATH") { 55 | Ok(path) => { 56 | let mut path_dirs: Vec<PathBuf> = env::split_paths(&path).collect(); 57 | path_dirs.insert(0, node_modules.join(".bin").into_std_path_buf()); 58 | // unwrap is safe, because we got the paths from the actual PATH variable 59 | env::set_var("PATH", env::join_paths(path_dirs).unwrap()); 60 | } 61 | Err(_) => warn!("Path PATH environment variable not found, ignoring"), 62 | } 63 | } else { 64 | warn!( 65 | "Path 'node_modules' folder not found, please install the required packages first" 66 | ); 67 | warn!("Path continuing without using 'node_modules'"); 68 | } 69 | } 70 | 71 | let _monitor = Interrupt::run_ctrl_c_monitor(); 72 | use Commands::{Build, EndToEnd, New, Serve, Test, Watch}; 73 | match args.command { 74 | Build(_) => command::build_all(&config).await, 75 | Serve(_) => command::serve(&config.current_project()?).await, 76 | Test(_) => command::test_all(&config).await, 77 | EndToEnd(_) => command::end2end_all(&config).await, 78 | Watch(_) => command::watch(&config.current_project()?).await, 79 | New(_) => unreachable!(r#""new" command should have already been run"#), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | //! TODO: port over formatting to `tracing-subscriber` 2 | //! Currently, `tracing` emits log events and `flexi_logger` consumes then. 3 | //! When you do implement `tracing-subscriber`, remember to add `tracing-error` error layer 4 | //! for `eyre`! 5 | 6 | use clap::builder::styling::{Ansi256Color, Color}; 7 | use flexi_logger::{ 8 | filter::{LogLineFilter, LogLineWriter}, 9 | DeferredNow, Level, Record, 10 | }; 11 | use std::{io::Write, sync::OnceLock}; 12 | 13 | use crate::{config::Log, ext::StrAdditions, internal_prelude::*}; 14 | 15 | const fn color(num: u8) -> Color { 16 | Color::Ansi256(Ansi256Color(num)) 17 | } 18 | 19 | const ERR_RED: Color = color(196); 20 | const WARN_YELLOW: Color = color(214); 21 | const INFO_GREEN: Color = color(77); 22 | const DBG_BLUE: Color = color(26); 23 | const TRACE_VIOLET: Color = color(98); 24 | pub const GRAY: Color = color(241); 25 | 26 | static LOG_SELECT: OnceLock<LogFlag> = OnceLock::new(); 27 | 28 | pub fn setup(verbose: u8, logs: &[Log]) { 29 | let log_level = match verbose { 30 | 0 => "info", 31 | 1 => "debug", 32 | _ => "trace", 33 | }; 34 | 35 | // OnceLock::get_or_try_init() is more idiomatic, but unstable at the moment 36 | _ = LOG_SELECT.get_or_init(|| { 37 | flexi_logger::Logger::try_with_str(log_level) 38 | .wrap_err_with(|| "Logger setup failed") 39 | .unwrap() 40 | .filter(Box::new(Filter)) 41 | .format(format) 42 | .start() 43 | .expect("Couldn't init cargo-leptos logger"); 44 | 45 | LogFlag::new(logs) 46 | }); 47 | } 48 | 49 | #[derive(Debug, Clone, Copy)] 50 | struct LogFlag(u8); 51 | 52 | impl LogFlag { 53 | fn new(logs: &[Log]) -> Self { 54 | Self(logs.iter().fold(0, |acc, f| acc | f.flag())) 55 | } 56 | 57 | fn is_set(&self, log: Log) -> bool { 58 | log.flag() & self.0 != 0 59 | } 60 | 61 | fn matches(&self, target: &str) -> bool { 62 | self.do_server_log(target) || self.do_wasm_log(target) 63 | } 64 | 65 | fn do_server_log(&self, target: &str) -> bool { 66 | self.is_set(Log::Server) && (target.starts_with("hyper") || target.starts_with("axum")) 67 | } 68 | 69 | fn do_wasm_log(&self, target: &str) -> bool { 70 | self.is_set(Log::Wasm) && (target.starts_with("wasm") || target.starts_with("walrus")) 71 | } 72 | } 73 | 74 | impl Log { 75 | fn flag(&self) -> u8 { 76 | match self { 77 | Self::Wasm => 0b0000_0001, 78 | Self::Server => 0b0000_0010, 79 | } 80 | } 81 | } 82 | 83 | // https://docs.rs/flexi_logger/0.24.1/flexi_logger/type.FormatFunction.html 84 | fn format( 85 | write: &mut dyn Write, 86 | _now: &mut DeferredNow, 87 | record: &Record<'_>, 88 | ) -> Result<(), std::io::Error> { 89 | let args = record.args().to_string(); 90 | 91 | let lvl_color = record.level().color(); 92 | 93 | if let Some(dep) = dependency(record) { 94 | let dep = format!("[{}]", dep); 95 | let dep = dep.pad_left_to(12); 96 | write!(write, "{} {}", lvl_color.paint(dep), record.args()) 97 | } else { 98 | let (word, rest) = split(&args); 99 | let word = word.pad_left_to(12); 100 | write!(write, "{} {rest}", lvl_color.paint(word)) 101 | } 102 | } 103 | 104 | fn split(args: &str) -> (&str, &str) { 105 | match args.find(' ') { 106 | Some(i) => (&args[..i], &args[i + 1..]), 107 | None => ("", args), 108 | } 109 | } 110 | fn dependency<'a>(record: &'a Record<'_>) -> Option<&'a str> { 111 | let target = record.target(); 112 | 113 | if !target.starts_with("cargo_leptos") { 114 | if let Some((ent, _)) = target.split_once("::") { 115 | return Some(ent); 116 | } 117 | } 118 | None 119 | } 120 | 121 | pub struct Filter; 122 | impl LogLineFilter for Filter { 123 | fn write( 124 | &self, 125 | now: &mut DeferredNow, 126 | record: &Record, 127 | log_line_writer: &dyn LogLineWriter, 128 | ) -> std::io::Result<()> { 129 | let target = record.target(); 130 | if record.level() == Level::Error 131 | || target.starts_with("cargo_leptos") 132 | // LOG_SELECT will have been initialized by now, get_or_init() not required 133 | || LOG_SELECT.get().is_some_and(|flag| flag.matches(target)) 134 | { 135 | log_line_writer.write(now, record)?; 136 | } 137 | Ok(()) 138 | } 139 | } 140 | 141 | trait LevelExt { 142 | fn color(&self) -> Color; 143 | } 144 | 145 | impl LevelExt for Level { 146 | fn color(&self) -> Color { 147 | match self { 148 | Level::Error => ERR_RED, 149 | Level::Warn => WARN_YELLOW, 150 | Level::Info => INFO_GREEN, 151 | Level::Debug => DBG_BLUE, 152 | Level::Trace => TRACE_VIOLET, 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use cargo_leptos::{config::Cli, run}; 2 | use clap::Parser; 3 | use std::env; 4 | 5 | #[tokio::main] 6 | async fn main() -> color_eyre::Result<()> { 7 | color_eyre::install()?; 8 | 9 | let mut args: Vec<String> = env::args().collect(); 10 | // when running as cargo leptos, the second argument is "leptos" which 11 | // clap doesn't expect 12 | if args.get(1).map(|a| a == "leptos").unwrap_or(false) { 13 | args.remove(1); 14 | } 15 | 16 | let args = Cli::parse_from(&args); 17 | 18 | let verbose = args.opts().map(|o| o.verbose).unwrap_or(0); 19 | cargo_leptos::logger::setup(verbose, &args.log); 20 | 21 | run(args).await 22 | } 23 | -------------------------------------------------------------------------------- /src/readme.md: -------------------------------------------------------------------------------- 1 | # Internals 2 | 3 | ## File view 4 | 5 | This is mainly relevant for the `watch` mode. 6 | 7 | ```mermaid 8 | graph TD; 9 | subgraph Watcher[watch] 10 | Watch[FS Notifier]; 11 | end 12 | Watch-->|"*.rs & input.css"| TailW; 13 | Watch-->|"*.sass & *.scss"| Sass; 14 | Watch-->|"*.css"| Append; 15 | Watch-->|"*.rs"| WASM; 16 | Watch-->|"*.rs"| BIN; 17 | Watch-->|"assets/**"| Mirror; 18 | 19 | subgraph style 20 | TailW[Tailwind CSS]; 21 | Sass; 22 | CSSProc[CSS Processor<br>Lightning CSS]; 23 | Append{{append}}; 24 | end 25 | 26 | TailW --> Append; 27 | Sass --> Append; 28 | Append --> CSSProc; 29 | 30 | subgraph rust 31 | WASM[Client WASM]; 32 | BIN[Server BIN]; 33 | end 34 | 35 | subgraph asset 36 | Mirror 37 | end 38 | 39 | subgraph update 40 | WOC[target/site/<br>Write-on-change FS]; 41 | Live[Live Reload]; 42 | Server; 43 | end 44 | 45 | Mirror -->|"site/**"| WOC; 46 | WASM -->|"site/pkg/app.wasm"| WOC; 47 | BIN -->|"server/app"| WOC; 48 | CSSProc -->|"site/pkg/app.css"| WOC; 49 | 50 | Live -.->|Port scan| Server; 51 | 52 | WOC -->|"target/server/app<br>site/**"| Server; 53 | WOC -->|"site/pkg/app.css, <br>client & server change"| Live; 54 | 55 | Live -->|"Reload all or<br>update app.css"| Browser 56 | 57 | Browser; 58 | Server -.- Browser; 59 | ``` 60 | 61 | ## Concurrency view 62 | 63 | Very approximate 64 | 65 | ```mermaid 66 | stateDiagram-v2 67 | wasm: Build front 68 | bin: Build server 69 | style: Build style 70 | asset: Mirror assets 71 | serve: Run server 72 | 73 | state wait_for_start <<fork>> 74 | [*] --> wait_for_start 75 | wait_for_start --> wasm 76 | wait_for_start --> bin 77 | wait_for_start --> style 78 | wait_for_start --> asset 79 | 80 | reload: Reload 81 | state join_state <<join>> 82 | wasm --> join_state 83 | bin --> join_state 84 | style --> join_state 85 | asset --> join_state 86 | state if_state <<choice>> 87 | join_state --> if_state 88 | if_state --> reload: Ok 89 | if_state --> serve: Ok 90 | if_state --> [*] : Err 91 | ``` 92 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod notify; 2 | pub mod reload; 3 | pub mod serve; 4 | pub mod site; 5 | -------------------------------------------------------------------------------- /src/service/reload.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use crate::{ 3 | config::Project, 4 | ext::{sync::wait_for_socket, Paint}, 5 | logger::GRAY, 6 | signal::{Interrupt, ReloadSignal, ReloadType}, 7 | }; 8 | use axum::{ 9 | extract::ws::{Message, WebSocket, WebSocketUpgrade}, 10 | response::IntoResponse, 11 | routing::get, 12 | Router, 13 | }; 14 | use serde::Serialize; 15 | use std::sync::LazyLock; 16 | use std::{fmt::Display, net::SocketAddr, sync::Arc}; 17 | use tokio::{ 18 | net::{TcpListener, TcpStream}, 19 | select, 20 | sync::RwLock, 21 | task::JoinHandle, 22 | }; 23 | 24 | static SITE_ADDR: LazyLock<RwLock<SocketAddr>> = 25 | LazyLock::new(|| RwLock::new(SocketAddr::new([127, 0, 0, 1].into(), 3000))); 26 | static CSS_LINK: LazyLock<RwLock<String>> = LazyLock::new(|| RwLock::new(String::default())); 27 | 28 | pub async fn spawn(proj: &Arc<Project>) -> JoinHandle<()> { 29 | let proj = proj.clone(); 30 | 31 | let mut site_addr = SITE_ADDR.write().await; 32 | *site_addr = proj.site.addr; 33 | if let Some(file) = &proj.style.file { 34 | let mut css_link = CSS_LINK.write().await; 35 | // Always use `/` as separator in links 36 | *css_link = file 37 | .site 38 | .components() 39 | .map(|c| c.as_str()) 40 | .collect::<Vec<_>>() 41 | .join("/"); 42 | } 43 | 44 | tokio::spawn(async move { 45 | let _change = ReloadSignal::subscribe(); 46 | 47 | let reload_addr = proj.site.reload; 48 | 49 | if TcpStream::connect(&reload_addr).await.is_ok() { 50 | error!( 51 | "Reload TCP port {reload_addr} already in use. You can set the port in the server integration's RenderOptions reload_port" 52 | ); 53 | Interrupt::request_shutdown().await; 54 | 55 | return; 56 | } 57 | let route = Router::new().route("/live_reload", get(websocket_handler)); 58 | 59 | debug!( 60 | "Reload server started {}", 61 | GRAY.paint(reload_addr.to_string()) 62 | ); 63 | 64 | match TcpListener::bind(&reload_addr).await { 65 | Ok(listener) => match axum::serve(listener, route).await { 66 | Ok(_) => debug!("Reload server stopped"), 67 | Err(e) => error!("Reload {e}"), 68 | }, 69 | Err(e) => error!("Reload {e}"), 70 | } 71 | }) 72 | } 73 | 74 | async fn websocket_handler(ws: WebSocketUpgrade) -> impl IntoResponse { 75 | ws.on_upgrade(websocket) 76 | } 77 | 78 | async fn websocket(mut stream: WebSocket) { 79 | let mut rx = ReloadSignal::subscribe(); 80 | let mut int = Interrupt::subscribe_any(); 81 | 82 | trace!("Reload websocket connected"); 83 | tokio::spawn(async move { 84 | loop { 85 | select! { 86 | res = rx.recv() =>{ 87 | match res { 88 | Ok(ReloadType::Full) => { 89 | send_and_close(stream, BrowserMessage::all()).await; 90 | return 91 | } 92 | Ok(ReloadType::Style) => { 93 | send(&mut stream, BrowserMessage::css().await).await; 94 | }, 95 | Ok(ReloadType::ViewPatches(data)) => { 96 | send(&mut stream, BrowserMessage::view(data)).await; 97 | } 98 | Err(e) => debug!("Reload recive error {e}") 99 | } 100 | } 101 | _ = int.recv(), if Interrupt::is_shutdown_requested().await => { 102 | trace!("Reload websocket closed"); 103 | return 104 | }, 105 | } 106 | } 107 | }); 108 | } 109 | 110 | async fn send(stream: &mut WebSocket, msg: BrowserMessage) { 111 | let site_addr = *SITE_ADDR.read().await; 112 | if !wait_for_socket("Reload", site_addr).await { 113 | warn!(r#"Reload could not send "{msg}" to websocket"#); 114 | } 115 | 116 | let text = serde_json::to_string(&msg).unwrap(); 117 | match stream.send(Message::Text(text.into())).await { 118 | Err(e) => { 119 | debug!("Reload could not send {msg} due to {e}"); 120 | } 121 | Ok(_) => { 122 | debug!(r#"Reload sent "{msg}" to browser"#); 123 | } 124 | } 125 | } 126 | 127 | async fn send_and_close(mut stream: WebSocket, msg: BrowserMessage) { 128 | send(&mut stream, msg).await; 129 | let _ = stream.send(Message::Close(None)).await; 130 | log::trace!("Reload websocket closed"); 131 | } 132 | 133 | #[derive(Serialize)] 134 | struct BrowserMessage { 135 | css: Option<String>, 136 | view: Option<String>, 137 | all: bool, 138 | } 139 | 140 | impl BrowserMessage { 141 | async fn css() -> Self { 142 | let link = CSS_LINK.read().await.clone(); 143 | if link.is_empty() { 144 | error!("Reload internal error: sending css reload but no css file is set."); 145 | } 146 | Self { 147 | css: Some(link), 148 | view: None, 149 | all: false, 150 | } 151 | } 152 | 153 | fn view(data: String) -> Self { 154 | Self { 155 | css: None, 156 | view: Some(data), 157 | all: false, 158 | } 159 | } 160 | 161 | fn all() -> Self { 162 | Self { 163 | css: None, 164 | view: None, 165 | all: true, 166 | } 167 | } 168 | } 169 | 170 | impl Display for BrowserMessage { 171 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 172 | if let Some(css) = &self.css { 173 | write!(f, "reload {}", css) 174 | } else { 175 | write!(f, "reload all") 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/service/serve.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::{ 4 | config::Project, 5 | ext::{append_str_to_filename, determine_pdb_filename, fs, Paint}, 6 | internal_prelude::*, 7 | logger::GRAY, 8 | signal::{Interrupt, ReloadSignal, ServerRestart}, 9 | }; 10 | use camino::Utf8PathBuf; 11 | use tokio::{ 12 | process::{Child, Command}, 13 | select, 14 | task::JoinHandle, 15 | }; 16 | 17 | pub async fn spawn(proj: &Arc<Project>) -> JoinHandle<Result<()>> { 18 | let mut int = Interrupt::subscribe_shutdown(); 19 | let proj = proj.clone(); 20 | let mut change = ServerRestart::subscribe(); 21 | tokio::spawn(async move { 22 | let mut server = ServerProcess::start_new(&proj).await?; 23 | loop { 24 | select! { 25 | res = change.recv() => { 26 | if let Ok(()) = res { 27 | server.restart().await?; 28 | ReloadSignal::send_full(); 29 | } 30 | }, 31 | _ = int.recv() => { 32 | server.kill().await; 33 | return Ok(()) 34 | }, 35 | } 36 | } 37 | }) 38 | } 39 | 40 | pub async fn spawn_oneshot(proj: &Arc<Project>) -> JoinHandle<Result<()>> { 41 | let mut int = Interrupt::subscribe_shutdown(); 42 | let proj = proj.clone(); 43 | tokio::spawn(async move { 44 | let mut server = ServerProcess::start_new(&proj).await?; 45 | select! { 46 | _ = server.wait() => {}, 47 | _ = int.recv() => { 48 | server.kill().await; 49 | }, 50 | }; 51 | Ok(()) 52 | }) 53 | } 54 | 55 | struct ServerProcess { 56 | process: Option<Child>, 57 | envs: Vec<(&'static str, String)>, 58 | binary: Utf8PathBuf, 59 | bin_args: Option<Vec<String>>, 60 | } 61 | 62 | impl ServerProcess { 63 | fn new(proj: &Project) -> Self { 64 | Self { 65 | process: None, 66 | envs: proj.to_envs(false), 67 | binary: proj.bin.exe_file.clone(), 68 | bin_args: proj.bin.bin_args.clone(), 69 | } 70 | } 71 | 72 | async fn start_new(proj: &Project) -> Result<Self> { 73 | let mut me = Self::new(proj); 74 | me.start().await?; 75 | Ok(me) 76 | } 77 | 78 | async fn kill(&mut self) { 79 | if let Some(proc) = self.process.as_mut() { 80 | if let Err(e) = proc.kill().await { 81 | error!("Serve error killing server process: {e}"); 82 | } else { 83 | trace!("Serve stopped"); 84 | } 85 | self.process = None; 86 | } 87 | } 88 | 89 | async fn restart(&mut self) -> Result<()> { 90 | self.kill().await; 91 | self.start().await?; 92 | trace!("Serve restarted"); 93 | Ok(()) 94 | } 95 | 96 | async fn wait(&mut self) -> Result<()> { 97 | if let Some(proc) = self.process.as_mut() { 98 | if let Err(e) = proc.wait().await { 99 | error!("Serve error while waiting for server process to exit: {e}"); 100 | } else { 101 | trace!("Serve process exited"); 102 | } 103 | } 104 | Ok(()) 105 | } 106 | 107 | async fn start(&mut self) -> Result<()> { 108 | let bin = &self.binary; 109 | let child = if bin.exists() { 110 | // windows doesn't like to overwrite a running binary, so we copy it to a new name 111 | let bin_path = if cfg!(target_os = "windows") { 112 | // solution to allow cargo to overwrite a running binary on some platforms: 113 | // copy cargo's output bin to [filename]_leptos and then run it 114 | let new_bin_path = append_str_to_filename(bin, "_leptos")?; 115 | debug!( 116 | "Copying server binary {} to {}", 117 | GRAY.paint(bin.as_str()), 118 | GRAY.paint(new_bin_path.as_str()) 119 | ); 120 | fs::copy(bin, &new_bin_path).await?; 121 | // also copy the .pdb file if it exists to allow debugging to attach 122 | if let Some(pdb) = determine_pdb_filename(bin) { 123 | let new_pdb_path = append_str_to_filename(&pdb, "_leptos")?; 124 | debug!( 125 | "Copying server binary debug info {} to {}", 126 | GRAY.paint(pdb.as_str()), 127 | GRAY.paint(new_pdb_path.as_str()) 128 | ); 129 | fs::copy(&pdb, &new_pdb_path).await?; 130 | } 131 | new_bin_path 132 | } else { 133 | bin.clone() 134 | }; 135 | 136 | let bin_args = match &self.bin_args { 137 | Some(bin_args) => bin_args.as_slice(), 138 | None => &[], 139 | }; 140 | 141 | debug!("Serve running {}", GRAY.paint(bin_path.as_str())); 142 | let cmd = Some( 143 | Command::new(bin_path) 144 | .envs(self.envs.clone()) 145 | .args(bin_args) 146 | .spawn()?, 147 | ); 148 | let port = self 149 | .envs 150 | .iter() 151 | .find_map(|(k, v)| { 152 | if k == &"LEPTOS_SITE_ADDR" { 153 | Some(v.to_string()) 154 | } else { 155 | None 156 | } 157 | }) 158 | .unwrap_or_default(); 159 | info!("Serving at http://{port}"); 160 | cmd 161 | } else { 162 | debug!("Serve no exe found {}", GRAY.paint(bin.as_str())); 163 | None 164 | }; 165 | self.process = child; 166 | Ok(()) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/service/site.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fmt::{self, Display}, 4 | net::SocketAddr, 5 | }; 6 | 7 | use camino::{Utf8Path, Utf8PathBuf}; 8 | use tokio::sync::RwLock; 9 | 10 | use crate::internal_prelude::*; 11 | use crate::{ 12 | config::ProjectConfig, 13 | ext::{fs, PathBufExt}, 14 | }; 15 | 16 | #[derive(Clone)] 17 | pub struct SourcedSiteFile { 18 | /// source file's relative path from the root (workspace or project) directory 19 | pub source: Utf8PathBuf, 20 | /// dest file's relative path from the root (workspace or project) directory 21 | pub dest: Utf8PathBuf, 22 | /// dest file's relative path from the site directory 23 | pub site: Utf8PathBuf, 24 | } 25 | 26 | impl SourcedSiteFile { 27 | pub fn as_site_file(&self) -> SiteFile { 28 | SiteFile { 29 | dest: self.dest.clone(), 30 | site: self.site.clone(), 31 | } 32 | } 33 | } 34 | 35 | impl std::fmt::Debug for SourcedSiteFile { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | f.debug_struct("SourcedSiteFile") 38 | .field("source", &self.source.test_string()) 39 | .field("dest", &self.dest.test_string()) 40 | .field("site", &self.site.test_string()) 41 | .finish() 42 | } 43 | } 44 | 45 | impl Display for SourcedSiteFile { 46 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 | write!(f, "{} -> @{}", self.source, self.site) 48 | } 49 | } 50 | 51 | #[derive(Clone)] 52 | pub struct SiteFile { 53 | /// dest file's relative path from the root (workspace or project) directory 54 | pub dest: Utf8PathBuf, 55 | /// dest file's relative path from the site directory 56 | pub site: Utf8PathBuf, 57 | } 58 | 59 | impl Display for SiteFile { 60 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 61 | write!(f, "@{}", self.site) 62 | } 63 | } 64 | 65 | impl std::fmt::Debug for SiteFile { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | f.debug_struct("SiteFile") 68 | .field("dest", &self.dest.test_string()) 69 | .field("site", &self.site.test_string()) 70 | .finish() 71 | } 72 | } 73 | 74 | pub struct Site { 75 | pub addr: SocketAddr, 76 | pub reload: SocketAddr, 77 | pub root_dir: Utf8PathBuf, 78 | pub pkg_dir: Utf8PathBuf, 79 | file_reg: RwLock<HashMap<String, u64>>, 80 | ext_file_reg: RwLock<HashMap<String, u64>>, 81 | } 82 | 83 | impl fmt::Debug for Site { 84 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 85 | f.debug_struct("Site") 86 | .field("addr", &self.addr) 87 | .field("reload", &self.reload) 88 | .field("root_dir", &self.root_dir) 89 | .field("pkg_dir", &self.pkg_dir) 90 | .field("file_reg", &self.file_reg.blocking_read()) 91 | .field("ext_file_reg", &self.ext_file_reg.blocking_read()) 92 | .finish() 93 | } 94 | } 95 | 96 | impl Site { 97 | pub fn new(config: &ProjectConfig) -> Self { 98 | let mut reload = config.site_addr; 99 | reload.set_port(config.reload_port); 100 | Self { 101 | addr: config.site_addr, 102 | reload, 103 | root_dir: config.site_root.clone(), 104 | pkg_dir: config.site_pkg_dir.clone(), 105 | file_reg: Default::default(), 106 | ext_file_reg: Default::default(), 107 | } 108 | } 109 | 110 | pub fn root_relative_pkg_dir(&self) -> Utf8PathBuf { 111 | self.root_dir.join(&self.pkg_dir) 112 | } 113 | /// check if the file changed 114 | pub async fn did_external_file_change(&self, to: &Utf8Path) -> Result<bool> { 115 | let new_hash = file_hash(to).await.dot()?; 116 | let cur_hash = { self.ext_file_reg.read().await.get(to.as_str()).copied() }; 117 | if Some(new_hash) == cur_hash { 118 | return Ok(false); 119 | } 120 | let mut f = self.ext_file_reg.write().await; 121 | f.insert(to.to_string(), new_hash); 122 | trace!("Site update hash for {to} to {new_hash}"); 123 | Ok(true) 124 | } 125 | 126 | pub async fn updated(&self, file: &SourcedSiteFile) -> Result<bool> { 127 | fs::create_dir_all(file.dest.clone().without_last()).await?; 128 | 129 | let new_hash = file_hash(&file.source).await?; 130 | let cur_hash = self.current_hash(&file.site, &file.dest).await?; 131 | 132 | if Some(new_hash) == cur_hash { 133 | return Ok(false); 134 | } 135 | fs::copy(&file.source, &file.dest).await?; 136 | 137 | let mut reg = self.file_reg.write().await; 138 | reg.insert(file.site.to_string(), new_hash); 139 | Ok(true) 140 | } 141 | 142 | /// check after writing the file if it changed 143 | pub async fn did_file_change(&self, file: &SiteFile) -> Result<bool> { 144 | let new_hash = file_hash(&file.dest).await.dot()?; 145 | let cur_hash = { self.file_reg.read().await.get(file.site.as_str()).copied() }; 146 | if Some(new_hash) == cur_hash { 147 | return Ok(false); 148 | } 149 | let mut f = self.file_reg.write().await; 150 | f.insert(file.site.to_string(), new_hash); 151 | Ok(true) 152 | } 153 | 154 | pub async fn updated_with(&self, file: &SiteFile, data: &[u8]) -> Result<bool> { 155 | fs::create_dir_all(file.dest.clone().without_last()).await?; 156 | 157 | let new_hash = seahash::hash(data); 158 | let cur_hash = self.current_hash(&file.site, &file.dest).await?; 159 | 160 | if Some(new_hash) == cur_hash { 161 | return Ok(false); 162 | } 163 | 164 | fs::write(&file.dest, &data).await?; 165 | 166 | let mut reg = self.file_reg.write().await; 167 | reg.insert(file.site.to_string(), new_hash); 168 | Ok(true) 169 | } 170 | 171 | async fn current_hash(&self, site: &Utf8Path, dest: &Utf8Path) -> Result<Option<u64>> { 172 | if let Some(hash) = self.file_reg.read().await.get(site.as_str()).copied() { 173 | Ok(Some(hash)) 174 | } else if dest.exists() { 175 | Ok(Some(file_hash(dest).await?)) 176 | } else { 177 | Ok(None) 178 | } 179 | } 180 | } 181 | 182 | async fn file_hash(file: &Utf8Path) -> Result<u64> { 183 | let data = fs::read(&file).await?; 184 | Ok(seahash::hash(&data)) 185 | } 186 | -------------------------------------------------------------------------------- /src/signal/interrupt.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | use tokio::{ 3 | signal, 4 | sync::{broadcast, RwLock}, 5 | task::JoinHandle, 6 | }; 7 | 8 | use crate::compile::{Change, ChangeSet}; 9 | use crate::internal_prelude::*; 10 | 11 | static ANY_INTERRUPT: LazyLock<broadcast::Sender<()>> = LazyLock::new(|| broadcast::channel(10).0); 12 | static SHUTDOWN: LazyLock<broadcast::Sender<()>> = LazyLock::new(|| broadcast::channel(1).0); 13 | 14 | static SHUTDOWN_REQUESTED: LazyLock<RwLock<bool>> = LazyLock::new(|| RwLock::new(false)); 15 | static SOURCE_CHANGES: LazyLock<RwLock<ChangeSet>> = 16 | LazyLock::new(|| RwLock::new(ChangeSet::default())); 17 | 18 | pub struct Interrupt {} 19 | 20 | impl Interrupt { 21 | pub async fn is_shutdown_requested() -> bool { 22 | *SHUTDOWN_REQUESTED.read().await 23 | } 24 | 25 | pub fn subscribe_any() -> broadcast::Receiver<()> { 26 | ANY_INTERRUPT.subscribe() 27 | } 28 | 29 | pub fn subscribe_shutdown() -> broadcast::Receiver<()> { 30 | SHUTDOWN.subscribe() 31 | } 32 | 33 | pub async fn get_source_changes() -> ChangeSet { 34 | SOURCE_CHANGES.read().await.clone() 35 | } 36 | 37 | pub async fn clear_source_changes() { 38 | let mut ch = SOURCE_CHANGES.write().await; 39 | ch.clear(); 40 | trace!("Interrupt source changed cleared"); 41 | } 42 | 43 | pub fn send_all_changed() { 44 | let mut ch = SOURCE_CHANGES.blocking_write(); 45 | *ch = ChangeSet::all_changes(); 46 | drop(ch); 47 | Self::send_any() 48 | } 49 | 50 | pub fn send(changes: &[Change]) { 51 | let mut ch = SOURCE_CHANGES.blocking_write(); 52 | for change in changes { 53 | ch.add(change.clone()); 54 | } 55 | drop(ch); 56 | 57 | Self::send_any(); 58 | } 59 | 60 | fn send_any() { 61 | if let Err(e) = ANY_INTERRUPT.send(()) { 62 | error!("Interrupt error could not send due to: {e}"); 63 | } else { 64 | trace!("Interrupt send done"); 65 | } 66 | } 67 | 68 | pub async fn request_shutdown() { 69 | { 70 | *SHUTDOWN_REQUESTED.write().await = true; 71 | } 72 | _ = SHUTDOWN.send(()); 73 | _ = ANY_INTERRUPT.send(()); 74 | } 75 | 76 | pub fn run_ctrl_c_monitor() -> JoinHandle<()> { 77 | tokio::spawn(async move { 78 | signal::ctrl_c().await.expect("failed to listen for event"); 79 | info!("Leptos ctrl-c received"); 80 | Interrupt::request_shutdown().await; 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/signal/mod.rs: -------------------------------------------------------------------------------- 1 | mod interrupt; 2 | mod product; 3 | mod reload; 4 | 5 | pub use interrupt::Interrupt; 6 | pub use product::{Outcome, Product, ProductSet, ServerRestart}; 7 | pub use reload::{ReloadSignal, ReloadType}; 8 | 9 | #[macro_export] 10 | macro_rules! location { 11 | () => { 12 | $crate::command::Location { 13 | file: file!().to_string(), 14 | line: line!(), 15 | column: column!(), 16 | } 17 | }; 18 | } 19 | 20 | pub struct Location { 21 | pub file: &'static str, 22 | pub line: u32, 23 | pub column: u32, 24 | pub modules: &'static str, 25 | } 26 | -------------------------------------------------------------------------------- /src/signal/product.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use derive_more::Display; 3 | use itertools::Itertools; 4 | use std::sync::LazyLock; 5 | use std::{collections::HashSet, fmt}; 6 | use tokio::sync::broadcast; 7 | 8 | static SERVER_RESTART_CHANNEL: LazyLock<broadcast::Sender<()>> = 9 | LazyLock::new(|| broadcast::channel::<()>(1).0); 10 | 11 | #[derive(Debug, PartialEq, Eq, Hash)] 12 | pub enum Outcome<T> { 13 | Success(T), 14 | Stopped, 15 | Failed, 16 | } 17 | 18 | impl<T> Outcome<T> { 19 | pub fn is_success(&self) -> bool { 20 | matches!(self, Outcome::Success(_)) 21 | } 22 | } 23 | 24 | #[derive(Debug, Display, Clone, PartialEq, Eq, Hash)] 25 | pub enum Product { 26 | Server, 27 | Front, 28 | Style(String), 29 | Assets, 30 | None, 31 | } 32 | 33 | #[derive(Debug, Clone, PartialEq, Eq)] 34 | pub struct ProductSet(HashSet<Product>); 35 | 36 | impl ProductSet { 37 | pub fn empty() -> Self { 38 | Self(HashSet::new()) 39 | } 40 | 41 | pub fn from(vec: Vec<Outcome<Product>>) -> Self { 42 | Self(HashSet::from_iter(vec.into_iter().filter_map( 43 | |entry| match entry { 44 | Outcome::Success(Product::None) => None, 45 | Outcome::Success(v) => Some(v), 46 | _ => None, 47 | }, 48 | ))) 49 | } 50 | 51 | pub fn is_empty(&self) -> bool { 52 | self.0.is_empty() 53 | } 54 | 55 | pub fn only_style(&self) -> bool { 56 | self.0.len() == 1 && self.0.iter().any(|p| matches!(p, Product::Style(_))) 57 | } 58 | 59 | pub fn contains(&self, product: &Product) -> bool { 60 | self.0.contains(product) 61 | } 62 | 63 | pub fn contains_any(&self, of: &[Product]) -> bool { 64 | of.iter().any(|p| self.0.contains(p)) 65 | } 66 | } 67 | 68 | impl fmt::Display for ProductSet { 69 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { 70 | write!( 71 | f, 72 | "{}", 73 | self.0 74 | .iter() 75 | .map(|f| f.to_string()) 76 | .collect_vec() 77 | .join(", ") 78 | ) 79 | } 80 | } 81 | 82 | pub struct ServerRestart {} 83 | 84 | impl ServerRestart { 85 | pub fn subscribe() -> broadcast::Receiver<()> { 86 | SERVER_RESTART_CHANNEL.subscribe() 87 | } 88 | 89 | pub fn send() { 90 | trace!("Server restart sent"); 91 | if let Err(e) = SERVER_RESTART_CHANNEL.send(()) { 92 | error!("Error could not send product changes due to {e}") 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/signal/reload.rs: -------------------------------------------------------------------------------- 1 | use crate::internal_prelude::*; 2 | use leptos_hot_reload::diff::Patches; 3 | use std::sync::LazyLock; 4 | use tokio::sync::broadcast; 5 | 6 | static RELOAD_CHANNEL: LazyLock<broadcast::Sender<ReloadType>> = 7 | LazyLock::new(|| broadcast::channel::<ReloadType>(1).0); 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum ReloadType { 11 | Full, 12 | Style, 13 | ViewPatches(String), 14 | } 15 | 16 | pub struct ReloadSignal {} 17 | 18 | impl ReloadSignal { 19 | pub fn send_full() { 20 | if let Err(e) = RELOAD_CHANNEL.send(ReloadType::Full) { 21 | error!(r#"Error could not send reload "Full" due to: {e}"#); 22 | } 23 | } 24 | pub fn send_style() { 25 | if let Err(e) = RELOAD_CHANNEL.send(ReloadType::Style) { 26 | error!(r#"Error could not send reload "Style" due to: {e}"#); 27 | } 28 | } 29 | 30 | pub fn send_view_patches(view_patches: &Patches) { 31 | match serde_json::to_string(view_patches) { 32 | Ok(data) => { 33 | if let Err(e) = RELOAD_CHANNEL.send(ReloadType::ViewPatches(data)) { 34 | error!(r#"Error could not send reload "View Patches" due to: {e}"#); 35 | } 36 | } 37 | Err(e) => error!(r#"Error could not send reload "View Patches" due to: {e}"#), 38 | } 39 | } 40 | 41 | pub fn subscribe() -> broadcast::Receiver<ReloadType> { 42 | RELOAD_CHANNEL.subscribe() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/snapshots/cargo_leptos__tests__workspace_build.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests.rs 3 | expression: site_dir.ls_ascii(0).unwrap_or_default() 4 | --- 5 | site: 6 | project1: 7 | favicon.ico 8 | pkg: 9 | project1.css 10 | project1.js 11 | project1.wasm 12 | project2: 13 | favicon.ico 14 | pkg: 15 | project2.css 16 | project2.js 17 | project2.wasm -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | 3 | use crate::{ 4 | config::{Cli, Commands, Opts}, 5 | run, 6 | }; 7 | 8 | #[tokio::test] 9 | async fn workspace_build() { 10 | let command = Commands::Build(Opts::default()); 11 | 12 | let cli = Cli { 13 | manifest_path: Some(Utf8PathBuf::from("examples/workspace/Cargo.toml")), 14 | log: Vec::new(), 15 | command, 16 | }; 17 | 18 | run(cli).await.unwrap(); 19 | 20 | // when running the current working directory is changed to the manifest path. 21 | // let site_dir = Utf8PathBuf::from("target/site"); 22 | 23 | //insta::assert_snapshot!(site_dir.ls_ascii(0).unwrap_or_default()); 24 | } 25 | 26 | // TODO: `cargo-leptos` sets the cwd which is a global env 27 | // and that prevents builds to run in parallel in the same process 28 | // 29 | // #[tokio::test] 30 | // async fn project_build() { 31 | // let command = Commands::Build(Opts::default()); 32 | 33 | // let cli = Cli { 34 | // manifest_path: Some(Utf8PathBuf::from("examples/project/Cargo.toml")), 35 | // log: Vec::new(), 36 | // command, 37 | // }; 38 | 39 | // run(cli).await.unwrap(); 40 | 41 | // // when running the current working directory is changed to the manifest path. 42 | // let site_dir = Utf8PathBuf::from("target/site"); 43 | 44 | // insta::assert_snapshot!(site_dir.ls_ascii(0).unwrap_or_default()); 45 | // } 46 | --------------------------------------------------------------------------------