mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 11:21:55 -05:00
Compare commits
17 Commits
v0.4.8
...
gbj-patch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722ea734d9 | ||
|
|
0b650ee2dc | ||
|
|
4def35cb45 | ||
|
|
0e56f27e0d | ||
|
|
bd8983f462 | ||
|
|
7ef635d9cf | ||
|
|
19ea6fae6a | ||
|
|
651a111db9 | ||
|
|
3a98bdb3c2 | ||
|
|
f01b982cff | ||
|
|
69dd96f76f | ||
|
|
329ae08e60 | ||
|
|
1e13ad8fee | ||
|
|
e0c9a9523a | ||
|
|
0726a3034d | ||
|
|
a88d047eff | ||
|
|
4001561987 |
5
.github/workflows/run-cargo-make-task.yml
vendored
5
.github/workflows/run-cargo-make-task.yml
vendored
@@ -84,6 +84,11 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Chrome Webriver
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install chromium-chromedriver
|
||||
|
||||
# Run Cargo Make Task
|
||||
- name: ${{ inputs.cargo_make_task }}
|
||||
run: |
|
||||
|
||||
@@ -26,7 +26,7 @@ cargo init leptos-tutorial
|
||||
cargo add leptos --features=csr,nightly
|
||||
```
|
||||
|
||||
Or you can leave off `nighly` if you're using stable Rust
|
||||
Or you can leave off `nightly` if you're using stable Rust
|
||||
```bash
|
||||
cargo add leptos --features=csr
|
||||
```
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
- [Responses and Redirects](./server/27_response.md)
|
||||
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
|
||||
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
|
||||
- [Deployment]()
|
||||
- [Deployment](./deployment.md)
|
||||
- [Appendix: Optimizing WASM Binary Size](./appendix_binary_size.md)
|
||||
|
||||
@@ -69,6 +69,34 @@ Every time one of the resources is reloading, the `"Loading..."` fallback will s
|
||||
|
||||
This inversion of the flow of control makes it easier to add or remove individual resources, as you don’t need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which we’ll talk about during a later chapter.
|
||||
|
||||
## `<Await/>`
|
||||
|
||||
In you’re simply trying to wait for some `Future` to resolve before rendering, you may find the `<Await/>` component helpful in reducing boilerplate. `<Await/>` essentially combines a resource with the source argument `|| ()` with a `<Suspense/>` with no fallback.
|
||||
|
||||
In other words:
|
||||
|
||||
1. It only polls the `Future` once, and does not respond to any reactive changes.
|
||||
2. It does not render anything until the `Future` resolves.
|
||||
3. After the `Future` resolves, its binds its data to whatever variable name you choose and then renders its children with that variable in scope.
|
||||
|
||||
```rust
|
||||
async fn fetch_monkeys(monkey: i32) -> i32 {
|
||||
// maybe this didn't need to be async
|
||||
monkey * 2
|
||||
}
|
||||
view! { cx,
|
||||
<Await
|
||||
// `future` provides the `Future` to be resolved
|
||||
future=|cx| fetch_monkeys(3)
|
||||
// the data is bound to whatever variable name you provide
|
||||
bind:data
|
||||
>
|
||||
// you receive the data by reference and can use it in your view here
|
||||
<p>{*data} " little monkeys, jumping on the bed."</p>
|
||||
</Await>
|
||||
}
|
||||
```
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs" width="100%" height="1000px" style="max-height: 100vh"></iframe>
|
||||
|
||||
74
docs/book/src/deployment.md
Normal file
74
docs/book/src/deployment.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Deployment
|
||||
|
||||
There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.
|
||||
|
||||
## General Advice
|
||||
|
||||
1. Remember: Always deploy Rust apps built in `--release` mode, not debug mode. This has a huge effect on both performance and binary size.
|
||||
2. Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so it’s possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, it’s likely a framework-level bug and you should open a GitHub issue with a reproduction.)
|
||||
|
||||
> We asked users to submit their deployment setups to help with this chapter. I’ll quote from them below, but you can read the full thread [here](https://github.com/leptos-rs/leptos/issues/1152).
|
||||
|
||||
## Deploying a Client-Side-Rendered App
|
||||
|
||||
If you’ve been building an app that only uses client-side rendering, working with Trunk as a dev server and build tool, the process is quite easy.
|
||||
|
||||
```bash
|
||||
trunk build --release
|
||||
```
|
||||
|
||||
`trunk build` will create a number of build artifacts in a `dist/` directory. Publishing `dist` somewhere online should be all you need to deploy your app. This should work very similarly to deploying any JavaScript application.
|
||||
|
||||
> Read more: [Deploying to Vercel with GitHub Actions](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1577861900).
|
||||
|
||||
## Deploying a Full-Stack App
|
||||
|
||||
The most popular way for people to deploy full-stack apps built with `cargo-leptos` is to use a cloud hosting service that supports deployment via a Docker build. Here’s a sample `Dockerfile`, which is based on the one we use to deploy the Leptos website.
|
||||
|
||||
```dockerfile
|
||||
# Get started with a build env with Rust nightly
|
||||
FROM rustlang/rust:nightly-bullseye as builder
|
||||
|
||||
# If you’re using stable, use this instead
|
||||
# FROM rust:1.70-bullseye as builder
|
||||
|
||||
# Install cargo-binstall, which makes it easier to install other
|
||||
# cargo extensions like cargo-leptos
|
||||
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
RUN cp cargo-binstall /usr/local/cargo/bin
|
||||
|
||||
# Install cargo-leptos
|
||||
RUN cargo binstall cargo-leptos -y
|
||||
|
||||
# Add the WASM target
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Make an /app dir, which everything will eventually live in
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN cargo leptos build --release -vv
|
||||
|
||||
FROM rustlang/rust:nightly-bullseye as runner
|
||||
# Copy the server binary to the /app directory
|
||||
COPY --from=builder /app/target/server/release/leptos_website /app/
|
||||
# /target/site contains our JS/WASM/CSS, etc.
|
||||
COPY --from=builder /app/target/site /app/site
|
||||
# Copy Cargo.toml if it’s needed at runtime
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
WORKDIR /app
|
||||
|
||||
# Set any required env variables and
|
||||
ENV RUST_LOG="info"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
EXPOSE 8080
|
||||
# Run the server
|
||||
CMD ["/app/leptos_website"]
|
||||
```
|
||||
|
||||
> Read more: [`gnu` and `musl` build files for Leptos apps](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1634916088).
|
||||
@@ -109,6 +109,34 @@ create_effect(cx, move |prev_value| {
|
||||
|
||||
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
|
||||
|
||||
## Explicit, Cancelable Tracking with `watch`
|
||||
|
||||
In addition to `create_effect`, Leptos provides a [`watch`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.watch.html) function, which can be used for two main purposes:
|
||||
|
||||
1. Separating tracking and responding to changes by explicitly passing in a set of values to track.
|
||||
2. Canceling tracking by calling a stop function.
|
||||
|
||||
Like `create_resource`, `watch` takes a first argument, which is reactively tracked, and a second, which is not. Whenever a reactive value in its `deps` argument is changed, the `callback` is run. `watch` returns a function that can be called to stop tracking the dependencies.
|
||||
|
||||
```rust
|
||||
let (num, set_num) = create_signal(cx, 0);
|
||||
|
||||
let stop = watch(
|
||||
cx,
|
||||
move || num.get(),
|
||||
move |num, prev_num, _| {
|
||||
log::debug!("Number: {}; Prev: {:?}", num, prev_num);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
set_num.set(1); // > "Number: 1; Prev: Some(0)"
|
||||
|
||||
stop(); // stop watching
|
||||
|
||||
set_num.set(2); // (nothing happens)
|
||||
```
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>
|
||||
|
||||
@@ -18,6 +18,20 @@ The router also provides an [`<A>`](https://docs.rs/leptos_router/latest/leptos_
|
||||
1. Correctly resolves relative nested routes. Relative routing with ordinary `<a>` tags can be tricky. For example, if you have a route like `/post/:id`, `<A href="1">` will generate the correct relative route, but `<a href="1">` likely will not (depending on where it appears in your view.) `<A/>` resolves routes relative to the path of the nested route within which it appears.
|
||||
2. Sets the `aria-current` attribute to `page` if this link is the active link (i.e., it’s a link to the page you’re on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if it’s a link to the page you’re currently on, you can match this attribute with a CSS selector.
|
||||
|
||||
## Navigating Programmatically
|
||||
|
||||
Your most-used methods of navigating between pages should be with `<a>` and `<form>` elements or with the enhanced `<A/>` and `<Form/>` components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.
|
||||
|
||||
On occasion, though, you’ll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the [`use_navigate`](https://docs.rs/leptos_router/latest/leptos_router/fn.use_navigate.html) function.
|
||||
```rust
|
||||
let navigate = leptos_router::use_navigate(cx);
|
||||
navigate("/somewhere", Default::default());
|
||||
```
|
||||
|
||||
> You should almost never do something like `<button on:click=move |_| navigate(/* ... */)>`. Any `on:click` that navigates should be an `<a>`, for reasons of accessibility.
|
||||
|
||||
The second argument here is a set of [`NavigateOptions`](https://docs.rs/leptos_router/latest/leptos_router/struct.NavigateOptions.html), which includes options to resolve the navigation relative to the current route as the `<A/>` component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.
|
||||
|
||||
> Once again, this is the same example. Check out the relative `<A/>` components, and take a look at the CSS in `index.html` to see the ARIA-based styling.
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)
|
||||
|
||||
@@ -62,7 +62,21 @@ pub async fn axum_extract(cx: Scope) -> Result<String, ServerFnError> {
|
||||
|
||||
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
|
||||
|
||||
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
|
||||
The Axum `extract` function only supports extractors for which the state is `()`. If you need an extractor that uses `State`, you should use [`extract_with_state`](https://docs.rs/leptos_axum/latest/leptos_axum/fn.extract_with_state.html). This requires you to provide the state. You can do this by extending the existing `LeptosOptions` state using the Axum `FromRef` pattern, which providing the state as context during render and server functions with custom handlers.
|
||||
|
||||
```rust
|
||||
use axum::extract::FromRef;
|
||||
|
||||
/// Derive FromRef to allow multiple items in state, using Axum’s
|
||||
/// SubStates pattern.
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState{
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool
|
||||
}
|
||||
```
|
||||
|
||||
[Click here for an example of providing context in custom handlers](https://github.com/leptos-rs/leptos/blob/19ea6fae6aec2a493d79cc86612622d219e6eebb/examples/session_auth_axum/src/main.rs#L24-L44).
|
||||
|
||||
## A Note about Data-Loading Patterns
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
extend = { path = "./cargo-leptos.toml" }
|
||||
|
||||
[tasks.integration-test]
|
||||
dependencies = ["cargo-leptos-e2e"]
|
||||
dependencies = ["install-cargo-leptos", "cargo-leptos-e2e"]
|
||||
|
||||
[tasks.cargo-leptos-e2e]
|
||||
command = "cargo"
|
||||
|
||||
55
examples/cargo-make/cargo-leptos.toml
Normal file
55
examples/cargo-make/cargo-leptos.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
[tasks.install-cargo-leptos]
|
||||
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
|
||||
|
||||
[tasks.build]
|
||||
clear = true
|
||||
command = "cargo"
|
||||
args = ["leptos", "build"]
|
||||
|
||||
[tasks.check]
|
||||
clear = true
|
||||
dependencies = ["check-debug", "check-release"]
|
||||
|
||||
[tasks.check-debug]
|
||||
toolchain = "nightly"
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check-release]
|
||||
toolchain = "nightly"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--release"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.start-client]
|
||||
command = "cargo"
|
||||
args = ["leptos", "watch"]
|
||||
|
||||
[tasks.stop-client]
|
||||
condition = { env_set = ["APP_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
pkill -f todo_app_sqlite
|
||||
fi
|
||||
|
||||
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
pkill -f cargo-leptos
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.client-status]
|
||||
condition = { env_set = ["APP_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
echo " ${APP_PROCESS_NAME} is not running"
|
||||
else
|
||||
echo " ${APP_PROCESS_NAME} is up"
|
||||
fi
|
||||
|
||||
if [ -z $(pidof cargo-leptos) ]; then
|
||||
echo " cargo-leptos is not running"
|
||||
else
|
||||
echo " cargo-leptos is up"
|
||||
fi
|
||||
'''
|
||||
30
examples/cargo-make/webdriver.toml
Normal file
30
examples/cargo-make/webdriver.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[tasks.start-webdriver]
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
RED="\e[0;31m"
|
||||
RESET="\e[0m"
|
||||
|
||||
if command -v chromedriver; then
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
chromedriver --port=4444 &
|
||||
fi
|
||||
else
|
||||
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.stop-webdriver]
|
||||
script = '''
|
||||
pkill -f "chromedriver"
|
||||
'''
|
||||
|
||||
[tasks.webdriver-status]
|
||||
script = '''
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
echo chromedriver is not running
|
||||
else
|
||||
echo chromedriver is up
|
||||
fi
|
||||
'''
|
||||
@@ -21,7 +21,7 @@ pub fn Nav(cx: Scope) -> impl IntoView {
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn Nav(cx: Scope) -> impl IntoView {
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -62,7 +62,8 @@ site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-cmd = "cargo make test-ui"
|
||||
end2end-dir = "e2e"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
extend = { path = "../cargo-make/main.toml" }
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/webdriver.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[tasks.check]
|
||||
clear = true
|
||||
dependencies = ["check-debug", "check-release"]
|
||||
[env]
|
||||
APP_PROCESS_NAME = "todo_app_sqlite"
|
||||
|
||||
[tasks.check-debug]
|
||||
toolchain = "nightly"
|
||||
[tasks.integration-test]
|
||||
dependencies = [
|
||||
"install-cargo-leptos",
|
||||
"start-webdriver",
|
||||
"test-e2e-with-auto-start",
|
||||
]
|
||||
|
||||
[tasks.test-e2e-with-auto-start]
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
args = ["leptos", "end-to-end"]
|
||||
|
||||
[tasks.check-release]
|
||||
toolchain = "nightly"
|
||||
[tasks.test-ui]
|
||||
cwd = "./e2e"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--release"]
|
||||
install_crate = "cargo-all-features"
|
||||
args = ["make", "test-ui", "${@}"]
|
||||
|
||||
@@ -52,3 +52,23 @@ wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This example includes quality checks and end-to-end testing.
|
||||
|
||||
To get started run this once.
|
||||
|
||||
```bash
|
||||
cargo make ci
|
||||
```
|
||||
|
||||
To only run the UI tests...
|
||||
|
||||
```bash
|
||||
cargo make start-webdriver
|
||||
cargo leptos watch # or cargo run...
|
||||
cargo make test-ui
|
||||
```
|
||||
|
||||
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._
|
||||
|
||||
Binary file not shown.
18
examples/todo_app_sqlite/e2e/Cargo.toml
Normal file
18
examples/todo_app_sqlite/e2e/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "todo_app_sqlite_e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.72"
|
||||
async-trait = "0.1.72"
|
||||
cucumber = "0.19.1"
|
||||
fantoccini = "0.19.3"
|
||||
pretty_assertions = "1.4.0"
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
url = "2.4.0"
|
||||
|
||||
[[test]]
|
||||
name = "manage_todos"
|
||||
harness = false # Allow Cucumber to print output instead of libtest
|
||||
11
examples/todo_app_sqlite/e2e/Makefile.toml
Normal file
11
examples/todo_app_sqlite/e2e/Makefile.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.test]
|
||||
env = { RUN_AUTOMATICALLY = false }
|
||||
condition = { env_true = ["RUN_AUTOMATICALLY"] }
|
||||
|
||||
[tasks.ci]
|
||||
|
||||
[tasks.test-ui]
|
||||
command = "cargo"
|
||||
args = ["test", "--test", "manage_todos", "--", "--fail-fast", "${@}"]
|
||||
33
examples/todo_app_sqlite/e2e/README.md
Normal file
33
examples/todo_app_sqlite/e2e/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# E2E Testing
|
||||
|
||||
This example demonstrates e2e testing with Rust using executable requirements.
|
||||
|
||||
## Testing Stack
|
||||
|
||||
| | Role | Description |
|
||||
|---|---|---|
|
||||
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
|
||||
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
|
||||
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
|
||||
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
|
||||
|
||||
## Testing Organization
|
||||
|
||||
Testing is organized around what a user can do and see/not see.
|
||||
|
||||
Here is a brief overview of how things fit together.
|
||||
|
||||
```bash
|
||||
features # Specify test scenarios
|
||||
tests
|
||||
├── fixtures
|
||||
│ ├── action.rs # Perform a user action (click, type, etc.)
|
||||
│ ├── check.rs # Assert what a user can see/not see
|
||||
│ ├── find.rs # Query page elements
|
||||
│ ├── mod.rs
|
||||
│ └── world
|
||||
│ ├── action_steps.rs # Map Gherkin steps to user actions
|
||||
│ ├── check_steps.rs # Map Gherkin steps to user expectations
|
||||
│ └── mod.rs
|
||||
└── manage_todos.rs # Test main
|
||||
```
|
||||
17
examples/todo_app_sqlite/e2e/features/add_todo.feature
Normal file
17
examples/todo_app_sqlite/e2e/features/add_todo.feature
Normal file
@@ -0,0 +1,17 @@
|
||||
@add_todo
|
||||
Feature: Add Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@add_todo-see
|
||||
Scenario: Should see the todo
|
||||
Given I set the todo as Buy Bread
|
||||
When I click the Add button
|
||||
Then I see the todo named Buy Bread
|
||||
|
||||
# @allow.skipped
|
||||
@add_todo-style
|
||||
Scenario: Should see the pending todo
|
||||
When I add a todo as Buy Oranges
|
||||
Then I see the pending todo
|
||||
18
examples/todo_app_sqlite/e2e/features/delete_todo.feature
Normal file
18
examples/todo_app_sqlite/e2e/features/delete_todo.feature
Normal file
@@ -0,0 +1,18 @@
|
||||
@delete_todo
|
||||
Feature: Delete Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@serial
|
||||
@delete_todo-remove
|
||||
Scenario: Should not see the deleted todo
|
||||
Given I add a todo as Buy Yogurt
|
||||
When I delete the todo named Buy Yogurt
|
||||
Then I do not see the todo named Buy Yogurt
|
||||
|
||||
@serial
|
||||
@delete_todo-message
|
||||
Scenario: Should see the empty list message
|
||||
When I empty the todo list
|
||||
Then I see the empty list message is No tasks were found.
|
||||
12
examples/todo_app_sqlite/e2e/features/open_app.feature
Normal file
12
examples/todo_app_sqlite/e2e/features/open_app.feature
Normal file
@@ -0,0 +1,12 @@
|
||||
@open_app
|
||||
Feature: Open App
|
||||
|
||||
@open_app-title
|
||||
Scenario: Should see the home page title
|
||||
When I open the app
|
||||
Then I see the page title is My Tasks
|
||||
|
||||
@open_app-label
|
||||
Scenario: Should see the input label
|
||||
When I open the app
|
||||
Then I see the label of the input is Add a Todo
|
||||
60
examples/todo_app_sqlite/e2e/tests/fixtures/action.rs
vendored
Normal file
60
examples/todo_app_sqlite/e2e/tests/fixtures/action.rs
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
use super::{find, world::HOST};
|
||||
use anyhow::Result;
|
||||
use fantoccini::Client;
|
||||
use std::result::Result::Ok;
|
||||
use tokio::{self, time};
|
||||
|
||||
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
|
||||
let url = format!("{}{}", HOST, path);
|
||||
client.goto(&url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
|
||||
fill_todo(client, text).await?;
|
||||
click_add_button(client).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
|
||||
let textbox = find::todo_input(client).await;
|
||||
textbox.send_keys(text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_add_button(client: &Client) -> Result<()> {
|
||||
let add_button = find::add_button(client).await;
|
||||
add_button.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn empty_todo_list(client: &Client) -> Result<()> {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for _todo in todos {
|
||||
let _ = delete_first_todo(client).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_first_todo(client: &Client) -> Result<()> {
|
||||
if let Some(element) = find::first_delete_button(client).await {
|
||||
element.click().await.expect("Failed to delete todo");
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
|
||||
if let Some(element) = find::delete_button(client, text).await {
|
||||
element.click().await?;
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
57
examples/todo_app_sqlite/e2e/tests/fixtures/check.rs
vendored
Normal file
57
examples/todo_app_sqlite/e2e/tests/fixtures/check.rs
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
use super::find;
|
||||
use anyhow::{Ok, Result};
|
||||
use fantoccini::{Client, Locator};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
pub async fn text_on_element(
|
||||
client: &Client,
|
||||
selector: &str,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Css(selector))
|
||||
.await
|
||||
.expect(
|
||||
format!("Element not found by Css selector `{}`", selector)
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let actual = element.text().await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn todo_present(
|
||||
client: &Client,
|
||||
text: &str,
|
||||
expected: bool,
|
||||
) -> Result<()> {
|
||||
let todo_present = is_todo_present(client, text).await;
|
||||
|
||||
assert_eq!(todo_present, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_todo_present(client: &Client, text: &str) -> bool {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for todo in todos {
|
||||
let todo_title = todo.text().await.expect("Todo title not found");
|
||||
if todo_title == text {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn todo_is_pending(client: &Client) -> Result<()> {
|
||||
if let None = find::pending_todo(client).await {
|
||||
assert!(false, "Pending todo not found");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
63
examples/todo_app_sqlite/e2e/tests/fixtures/find.rs
vendored
Normal file
63
examples/todo_app_sqlite/e2e/tests/fixtures/find.rs
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
use fantoccini::{elements::Element, Client, Locator};
|
||||
|
||||
pub async fn todo_input(client: &Client) -> Element {
|
||||
let textbox = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[name='title"))
|
||||
.await
|
||||
.expect("Todo textbox not found");
|
||||
|
||||
textbox
|
||||
}
|
||||
|
||||
pub async fn add_button(client: &Client) -> Element {
|
||||
let button = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[value='Add']"))
|
||||
.await
|
||||
.expect("");
|
||||
|
||||
button
|
||||
}
|
||||
|
||||
pub async fn first_delete_button(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("li:first-child input[value='X']"))
|
||||
.await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
|
||||
let selector = format!("//*[text()='{text}']//input[@value='X']");
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::XPath(&selector)).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn pending_todo(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::Css(".pending")).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn todos(client: &Client) -> Vec<Element> {
|
||||
let todos = client
|
||||
.find_all(Locator::Css("li"))
|
||||
.await
|
||||
.expect("Todo List not found");
|
||||
|
||||
todos
|
||||
}
|
||||
4
examples/todo_app_sqlite/e2e/tests/fixtures/mod.rs
vendored
Normal file
4
examples/todo_app_sqlite/e2e/tests/fixtures/mod.rs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod action;
|
||||
pub mod check;
|
||||
pub mod find;
|
||||
pub mod world;
|
||||
57
examples/todo_app_sqlite/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
57
examples/todo_app_sqlite/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::fixtures::{action, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::{given, when};
|
||||
|
||||
#[given("I see the app")]
|
||||
#[when("I open the app")]
|
||||
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::goto_path(client, "").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I add a todo as (.*)$")]
|
||||
#[when(regex = "^I add a todo as (.*)$")]
|
||||
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::add_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I set the todo as (.*)$")]
|
||||
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::fill_todo(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "I click the Add button$")]
|
||||
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_add_button(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "^I delete the todo named (.*)$")]
|
||||
async fn i_delete_the_todo_named(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::delete_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given("the todo list is empty")]
|
||||
#[when("I empty the todo list")]
|
||||
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::empty_todo_list(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
67
examples/todo_app_sqlite/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
67
examples/todo_app_sqlite/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
use crate::fixtures::{check, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
|
||||
#[then(regex = "^I see the page title is (.*)$")]
|
||||
async fn i_see_the_page_title_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "h1", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the label of the input is (.*)$")]
|
||||
async fn i_see_the_label_of_the_input_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "label", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the todo named (.*)$")]
|
||||
async fn i_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), true).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then("I see the pending todo")]
|
||||
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
check::todo_is_pending(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the empty list message is (.*)$")]
|
||||
async fn i_see_the_empty_list_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "ul p", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I do not see the todo named (.*)$")]
|
||||
async fn i_do_not_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), false).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
39
examples/todo_app_sqlite/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
39
examples/todo_app_sqlite/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
pub mod action_steps;
|
||||
pub mod check_steps;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fantoccini::{
|
||||
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
|
||||
};
|
||||
|
||||
pub const HOST: &str = "http://127.0.0.1:3000";
|
||||
|
||||
#[derive(Debug, World)]
|
||||
#[world(init = Self::new)]
|
||||
pub struct AppWorld {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppWorld {
|
||||
async fn new() -> Result<Self, anyhow::Error> {
|
||||
let webdriver_client = build_client().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: webdriver_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_client() -> Result<Client, NewSessionError> {
|
||||
let mut cap = Capabilities::new();
|
||||
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
|
||||
cap.insert("goog:chromeOptions".to_string(), arg);
|
||||
|
||||
let client = ClientBuilder::native()
|
||||
.capabilities(cap)
|
||||
.connect("http://localhost:4444")
|
||||
.await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
14
examples/todo_app_sqlite/e2e/tests/manage_todos.rs
Normal file
14
examples/todo_app_sqlite/e2e/tests/manage_todos.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod fixtures;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fixtures::world::AppWorld;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
AppWorld::cucumber()
|
||||
.fail_on_skipped()
|
||||
.run_and_exit("./features")
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
mod todo;
|
||||
|
||||
// boilerplate to run in different modes
|
||||
@@ -9,6 +8,7 @@ cfg_if! {
|
||||
use actix_files::{Files};
|
||||
use actix_web::*;
|
||||
use crate::todo::*;
|
||||
use leptos::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
|
||||
#[get("/style.css")]
|
||||
|
||||
@@ -65,7 +65,8 @@ site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-cmd = "cargo make test-ui"
|
||||
end2end-dir = "e2e"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
extend = { path = "../cargo-make/main.toml" }
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/webdriver.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
APP_PROCESS_NAME = "todo_app_sqlite_axum"
|
||||
|
||||
[tasks.integration-test]
|
||||
dependencies = [
|
||||
"install-cargo-leptos",
|
||||
"start-webdriver",
|
||||
"test-e2e-with-auto-start",
|
||||
]
|
||||
|
||||
[tasks.test-e2e-with-auto-start]
|
||||
command = "cargo"
|
||||
args = ["leptos", "end-to-end"]
|
||||
|
||||
[tasks.test-ui]
|
||||
cwd = "./e2e"
|
||||
command = "cargo"
|
||||
args = ["make", "test-ui", "${@}"]
|
||||
|
||||
@@ -40,3 +40,23 @@ wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This example includes quality checks and end-to-end testing.
|
||||
|
||||
To get started run this once.
|
||||
|
||||
```bash
|
||||
cargo make ci
|
||||
```
|
||||
|
||||
To only run the UI tests...
|
||||
|
||||
```bash
|
||||
cargo make start-webdriver
|
||||
cargo leptos watch # or cargo run...
|
||||
cargo make test-ui
|
||||
```
|
||||
|
||||
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._
|
||||
18
examples/todo_app_sqlite_axum/e2e/Cargo.toml
Normal file
18
examples/todo_app_sqlite_axum/e2e/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "todo_app_sqlite_axum_e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.72"
|
||||
async-trait = "0.1.72"
|
||||
cucumber = "0.19.1"
|
||||
fantoccini = "0.19.3"
|
||||
pretty_assertions = "1.4.0"
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
url = "2.4.0"
|
||||
|
||||
[[test]]
|
||||
name = "manage_todos"
|
||||
harness = false # Allow Cucumber to print output instead of libtest
|
||||
11
examples/todo_app_sqlite_axum/e2e/Makefile.toml
Normal file
11
examples/todo_app_sqlite_axum/e2e/Makefile.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.test]
|
||||
env = { RUN_AUTOMATICALLY = false }
|
||||
condition = { env_true = ["RUN_AUTOMATICALLY"] }
|
||||
|
||||
[tasks.ci]
|
||||
|
||||
[tasks.test-ui]
|
||||
command = "cargo"
|
||||
args = ["test", "--test", "manage_todos", "--", "--fail-fast", "${@}"]
|
||||
33
examples/todo_app_sqlite_axum/e2e/README.md
Normal file
33
examples/todo_app_sqlite_axum/e2e/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# E2E Testing
|
||||
|
||||
This example demonstrates e2e testing with Rust using executable requirements.
|
||||
|
||||
## Testing Stack
|
||||
|
||||
| | Role | Description |
|
||||
|---|---|---|
|
||||
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
|
||||
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
|
||||
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
|
||||
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
|
||||
|
||||
## Testing Organization
|
||||
|
||||
Testing is organized around what a user can do and see/not see.
|
||||
|
||||
Here is a brief overview of how things fit together.
|
||||
|
||||
```bash
|
||||
features # Specify test scenarios
|
||||
tests
|
||||
├── fixtures
|
||||
│ ├── action.rs # Perform a user action (click, type, etc.)
|
||||
│ ├── check.rs # Assert what a user can see/not see
|
||||
│ ├── find.rs # Query page elements
|
||||
│ ├── mod.rs
|
||||
│ └── world
|
||||
│ ├── action_steps.rs # Map Gherkin steps to user actions
|
||||
│ ├── check_steps.rs # Map Gherkin steps to user expectations
|
||||
│ └── mod.rs
|
||||
└── manage_todos.rs # Test main
|
||||
```
|
||||
16
examples/todo_app_sqlite_axum/e2e/features/add_todo.feature
Normal file
16
examples/todo_app_sqlite_axum/e2e/features/add_todo.feature
Normal file
@@ -0,0 +1,16 @@
|
||||
@add_todo
|
||||
Feature: Add Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@add_todo-see
|
||||
Scenario: Should see the todo
|
||||
Given I set the todo as Buy Bread
|
||||
When I click the Add button
|
||||
Then I see the todo named Buy Bread
|
||||
|
||||
@add_todo-style
|
||||
Scenario: Should see the pending todo
|
||||
When I add a todo as Buy Oranges
|
||||
Then I see the pending todo
|
||||
@@ -0,0 +1,18 @@
|
||||
@delete_todo
|
||||
Feature: Delete Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@serial
|
||||
@delete_todo-remove
|
||||
Scenario: Should not see the deleted todo
|
||||
Given I add a todo as Buy Yogurt
|
||||
When I delete the todo named Buy Yogurt
|
||||
Then I do not see the todo named Buy Yogurt
|
||||
|
||||
@serial
|
||||
@delete_todo-message
|
||||
Scenario: Should see the empty list message
|
||||
When I empty the todo list
|
||||
Then I see the empty list message is No tasks were found.
|
||||
12
examples/todo_app_sqlite_axum/e2e/features/open_app.feature
Normal file
12
examples/todo_app_sqlite_axum/e2e/features/open_app.feature
Normal file
@@ -0,0 +1,12 @@
|
||||
@open_app
|
||||
Feature: Open App
|
||||
|
||||
@open_app-title
|
||||
Scenario: Should see the home page title
|
||||
When I open the app
|
||||
Then I see the page title is My Tasks
|
||||
|
||||
@open_app-label
|
||||
Scenario: Should see the input label
|
||||
When I open the app
|
||||
Then I see the label of the input is Add a Todo
|
||||
60
examples/todo_app_sqlite_axum/e2e/tests/fixtures/action.rs
vendored
Normal file
60
examples/todo_app_sqlite_axum/e2e/tests/fixtures/action.rs
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
use super::{find, world::HOST};
|
||||
use anyhow::Result;
|
||||
use fantoccini::Client;
|
||||
use std::result::Result::Ok;
|
||||
use tokio::{self, time};
|
||||
|
||||
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
|
||||
let url = format!("{}{}", HOST, path);
|
||||
client.goto(&url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
|
||||
fill_todo(client, text).await?;
|
||||
click_add_button(client).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
|
||||
let textbox = find::todo_input(client).await;
|
||||
textbox.send_keys(text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_add_button(client: &Client) -> Result<()> {
|
||||
let add_button = find::add_button(client).await;
|
||||
add_button.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn empty_todo_list(client: &Client) -> Result<()> {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for _todo in todos {
|
||||
let _ = delete_first_todo(client).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_first_todo(client: &Client) -> Result<()> {
|
||||
if let Some(element) = find::first_delete_button(client).await {
|
||||
element.click().await.expect("Failed to delete todo");
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
|
||||
if let Some(element) = find::delete_button(client, text).await {
|
||||
element.click().await?;
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
57
examples/todo_app_sqlite_axum/e2e/tests/fixtures/check.rs
vendored
Normal file
57
examples/todo_app_sqlite_axum/e2e/tests/fixtures/check.rs
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
use super::find;
|
||||
use anyhow::{Ok, Result};
|
||||
use fantoccini::{Client, Locator};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
pub async fn text_on_element(
|
||||
client: &Client,
|
||||
selector: &str,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Css(selector))
|
||||
.await
|
||||
.expect(
|
||||
format!("Element not found by Css selector `{}`", selector)
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let actual = element.text().await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn todo_present(
|
||||
client: &Client,
|
||||
text: &str,
|
||||
expected: bool,
|
||||
) -> Result<()> {
|
||||
let todo_present = is_todo_present(client, text).await;
|
||||
|
||||
assert_eq!(todo_present, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_todo_present(client: &Client, text: &str) -> bool {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for todo in todos {
|
||||
let todo_title = todo.text().await.expect("Todo title not found");
|
||||
if todo_title == text {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn todo_is_pending(client: &Client) -> Result<()> {
|
||||
if let None = find::pending_todo(client).await {
|
||||
assert!(false, "Pending todo not found");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
63
examples/todo_app_sqlite_axum/e2e/tests/fixtures/find.rs
vendored
Normal file
63
examples/todo_app_sqlite_axum/e2e/tests/fixtures/find.rs
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
use fantoccini::{elements::Element, Client, Locator};
|
||||
|
||||
pub async fn todo_input(client: &Client) -> Element {
|
||||
let textbox = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[name='title"))
|
||||
.await
|
||||
.expect("Todo textbox not found");
|
||||
|
||||
textbox
|
||||
}
|
||||
|
||||
pub async fn add_button(client: &Client) -> Element {
|
||||
let button = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[value='Add']"))
|
||||
.await
|
||||
.expect("");
|
||||
|
||||
button
|
||||
}
|
||||
|
||||
pub async fn first_delete_button(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("li:first-child input[value='X']"))
|
||||
.await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
|
||||
let selector = format!("//*[text()='{text}']//input[@value='X']");
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::XPath(&selector)).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn pending_todo(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::Css(".pending")).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn todos(client: &Client) -> Vec<Element> {
|
||||
let todos = client
|
||||
.find_all(Locator::Css("li"))
|
||||
.await
|
||||
.expect("Todo List not found");
|
||||
|
||||
todos
|
||||
}
|
||||
4
examples/todo_app_sqlite_axum/e2e/tests/fixtures/mod.rs
vendored
Normal file
4
examples/todo_app_sqlite_axum/e2e/tests/fixtures/mod.rs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod action;
|
||||
pub mod check;
|
||||
pub mod find;
|
||||
pub mod world;
|
||||
57
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
57
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::fixtures::{action, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::{given, when};
|
||||
|
||||
#[given("I see the app")]
|
||||
#[when("I open the app")]
|
||||
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::goto_path(client, "").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I add a todo as (.*)$")]
|
||||
#[when(regex = "^I add a todo as (.*)$")]
|
||||
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::add_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I set the todo as (.*)$")]
|
||||
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::fill_todo(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "I click the Add button$")]
|
||||
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_add_button(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "^I delete the todo named (.*)$")]
|
||||
async fn i_delete_the_todo_named(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::delete_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given("the todo list is empty")]
|
||||
#[when("I empty the todo list")]
|
||||
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::empty_todo_list(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
67
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
67
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
use crate::fixtures::{check, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
|
||||
#[then(regex = "^I see the page title is (.*)$")]
|
||||
async fn i_see_the_page_title_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "h1", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the label of the input is (.*)$")]
|
||||
async fn i_see_the_label_of_the_input_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "label", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the todo named (.*)$")]
|
||||
async fn i_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), true).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then("I see the pending todo")]
|
||||
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
check::todo_is_pending(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the empty list message is (.*)$")]
|
||||
async fn i_see_the_empty_list_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "ul p", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I do not see the todo named (.*)$")]
|
||||
async fn i_do_not_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), false).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
39
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
39
examples/todo_app_sqlite_axum/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
pub mod action_steps;
|
||||
pub mod check_steps;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fantoccini::{
|
||||
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
|
||||
};
|
||||
|
||||
pub const HOST: &str = "http://127.0.0.1:3000";
|
||||
|
||||
#[derive(Debug, World)]
|
||||
#[world(init = Self::new)]
|
||||
pub struct AppWorld {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppWorld {
|
||||
async fn new() -> Result<Self, anyhow::Error> {
|
||||
let webdriver_client = build_client().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: webdriver_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_client() -> Result<Client, NewSessionError> {
|
||||
let mut cap = Capabilities::new();
|
||||
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
|
||||
cap.insert("goog:chromeOptions".to_string(), arg);
|
||||
|
||||
let client = ClientBuilder::native()
|
||||
.capabilities(cap)
|
||||
.connect("http://localhost:4444")
|
||||
.await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
14
examples/todo_app_sqlite_axum/e2e/tests/manage_todos.rs
Normal file
14
examples/todo_app_sqlite_axum/e2e/tests/manage_todos.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod fixtures;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fixtures::world::AppWorld;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
AppWorld::cucumber()
|
||||
.fail_on_skipped()
|
||||
.run_and_exit("./features")
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,7 +13,7 @@ cfg_if! {
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
_ = console_log::init_with_level(log::Level::Error);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(|cx| {
|
||||
|
||||
@@ -29,7 +29,7 @@ cfg_if! {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
|
||||
simple_logger::init_with_level(log::Level::Error).expect("couldn't initialize logging");
|
||||
|
||||
let _conn = db().await.expect("couldn't connect to DB");
|
||||
/* sqlx::migrate!()
|
||||
|
||||
@@ -14,7 +14,7 @@ fn autoreload(nonce_str: &str, options: &LeptosOptions) -> String {
|
||||
r#"
|
||||
<script crossorigin=""{nonce_str}>(function () {{
|
||||
{}
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
let ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
if (msg.all) window.location.reload();
|
||||
|
||||
@@ -4,7 +4,7 @@ version = { workspace = true }
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gbj/leptos"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces."
|
||||
readme = "../README.md"
|
||||
|
||||
|
||||
@@ -52,12 +52,12 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
<Outlet/>
|
||||
}
|
||||
>
|
||||
<Route path="" view=Nested
|
||||
<Route path="inside" view=NestedResourceInside
|
||||
<Route path="single" view=Single
|
||||
<Route path="parallel" view=Parallel
|
||||
<Route path="inside-component" view=InsideComponent
|
||||
<Route path="none" view=None
|
||||
<Route path="" view=Nested/>
|
||||
<Route path="inside" view=NestedResourceInside/>
|
||||
<Route path="single" view=Single/>
|
||||
<Route path="parallel" view=Parallel/>
|
||||
<Route path="inside-component" view=InsideComponent/>
|
||||
<Route path="none" view=None/>
|
||||
</Route>
|
||||
// in-order
|
||||
<Route
|
||||
@@ -69,12 +69,12 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
<Outlet/>
|
||||
}
|
||||
>
|
||||
<Route path="" view=Nested
|
||||
<Route path="inside" view=NestedResourceInside
|
||||
<Route path="single" view=Single
|
||||
<Route path="parallel" view=Parallel
|
||||
<Route path="inside-component" view=InsideComponent
|
||||
<Route path="none" view=None
|
||||
<Route path="" view=Nested/>
|
||||
<Route path="inside" view=NestedResourceInside/>
|
||||
<Route path="single" view=Single/>
|
||||
<Route path="parallel" view=Parallel/>
|
||||
<Route path="inside-component" view=InsideComponent/>
|
||||
<Route path="none" view=None/>
|
||||
</Route>
|
||||
// async
|
||||
<Route
|
||||
@@ -86,12 +86,12 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
<Outlet/>
|
||||
}
|
||||
>
|
||||
<Route path="" view=Nested
|
||||
<Route path="inside" view=NestedResourceInside
|
||||
<Route path="single" view=Single
|
||||
<Route path="parallel" view=Parallel
|
||||
<Route path="inside-component" view=InsideComponent
|
||||
<Route path="none" view=None
|
||||
<Route path="" view=Nested/>
|
||||
<Route path="inside" view=NestedResourceInside/>
|
||||
<Route path="single" view=Single/>
|
||||
<Route path="parallel" view=Parallel/>
|
||||
<Route path="inside-component" view=InsideComponent/>
|
||||
<Route path="none" view=None/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -7,7 +7,7 @@ use leptos_reactive::Scope;
|
||||
use std::{borrow::Cow, cell::RefCell, fmt, ops::Deref, rc::Rc};
|
||||
cfg_if! {
|
||||
if #[cfg(all(target_arch = "wasm32", feature = "web"))] {
|
||||
use crate::{mount_child, prepare_to_move, unmount_child, MountKind, Mountable};
|
||||
use crate::{mount_child, prepare_to_move, unmount_child, MountKind, Mountable, Text};
|
||||
use leptos_reactive::{create_effect, ScopeDisposer};
|
||||
use wasm_bindgen::JsCast;
|
||||
}
|
||||
@@ -347,32 +347,29 @@ where
|
||||
}
|
||||
// Otherwise, we know for sure this is our first time
|
||||
else {
|
||||
// We need to remove the text created from SSR
|
||||
if HydrationCtx::is_hydrating()
|
||||
// If it's a text node, we want to use the old text node
|
||||
// as the text node for the DynChild, rather than the new
|
||||
// text node being created during hydration
|
||||
let new_child = if HydrationCtx::is_hydrating()
|
||||
&& new_child.get_text().is_some()
|
||||
{
|
||||
let t = closing
|
||||
.previous_non_view_marker_sibling()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::Element>();
|
||||
.unchecked_into::<web_sys::Text>();
|
||||
|
||||
// See note on ssr.rs when matching on `DynChild`
|
||||
// for more details on why we need to do this for
|
||||
// release
|
||||
if !cfg!(debug_assertions) {
|
||||
t.previous_sibling()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::Element>()
|
||||
.remove();
|
||||
}
|
||||
|
||||
t.remove();
|
||||
|
||||
mount_child(
|
||||
MountKind::Before(&closing),
|
||||
&new_child,
|
||||
);
|
||||
}
|
||||
let new_child = match new_child {
|
||||
View::Text(text) => text,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
t.set_data(&new_child.content);
|
||||
View::Text(Text {
|
||||
node: t.unchecked_into(),
|
||||
content: new_child.content,
|
||||
})
|
||||
} else {
|
||||
new_child
|
||||
};
|
||||
|
||||
// If we are not hydrating, we simply mount the child
|
||||
if !HydrationCtx::is_hydrating() {
|
||||
|
||||
@@ -357,10 +357,10 @@ fn fragments_to_chunks(
|
||||
r#"
|
||||
<template id="{fragment_id}f">{html}</template>
|
||||
<script{nonce_str}>
|
||||
var id = "{fragment_id}";
|
||||
var open = undefined;
|
||||
var close = undefined;
|
||||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
|
||||
(function() {{ let id = "{fragment_id}";
|
||||
let open = undefined;
|
||||
let close = undefined;
|
||||
let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
|
||||
while(walker.nextNode()) {{
|
||||
if(walker.currentNode.textContent == `suspense-open-${{id}}`) {{
|
||||
open = walker.currentNode;
|
||||
@@ -368,12 +368,12 @@ fn fragments_to_chunks(
|
||||
close = walker.currentNode;
|
||||
}}
|
||||
}}
|
||||
var range = new Range();
|
||||
let range = new Range();
|
||||
range.setStartAfter(open);
|
||||
range.setEndBefore(close);
|
||||
range.deleteContents();
|
||||
var tpl = document.getElementById("{fragment_id}f");
|
||||
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);
|
||||
let tpl = document.getElementById("{fragment_id}f");
|
||||
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);}})()
|
||||
</script>
|
||||
"#
|
||||
)
|
||||
@@ -483,26 +483,36 @@ impl View {
|
||||
true,
|
||||
Box::new(move || {
|
||||
if let Some(child) = *child {
|
||||
// On debug builds, `DynChild` has two marker nodes,
|
||||
// so there is no way for the text to be merged with
|
||||
// surrounding text when the browser parses the HTML,
|
||||
// but in release, `DynChild` only has a trailing marker,
|
||||
// and the browser automatically merges the dynamic text
|
||||
// into one single node, so we need to artificially make the
|
||||
// browser create the dynamic text as it's own text node
|
||||
if let View::Text(t) = child {
|
||||
if !cfg!(debug_assertions) {
|
||||
format!(
|
||||
"<!>{}",
|
||||
html_escape::encode_safe(
|
||||
&t.content
|
||||
)
|
||||
)
|
||||
.into()
|
||||
// if we don't check if the string is empty,
|
||||
// the HTML is an empty string; but an empty string
|
||||
// is not a text node in HTML, so can't be updated
|
||||
// in the future. so we put a one-space text node instead
|
||||
let was_empty = t.content.is_empty();
|
||||
let content = if was_empty {
|
||||
" ".into()
|
||||
} else {
|
||||
html_escape::encode_safe(&t.content)
|
||||
t.content
|
||||
};
|
||||
// escape content unless we're in a <script> or <style>
|
||||
let content = if dont_escape_text {
|
||||
content
|
||||
} else {
|
||||
html_escape::encode_safe(&content)
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
// On debug builds, `DynChild` has two marker nodes,
|
||||
// so there is no way for the text to be merged with
|
||||
// surrounding text when the browser parses the HTML,
|
||||
// but in release, `DynChild` only has a trailing marker,
|
||||
// and the browser automatically merges the dynamic text
|
||||
// into one single node, so we need to artificially make the
|
||||
// browser create the dynamic text as it's own text node
|
||||
if !cfg!(debug_assertions) {
|
||||
format!("<!>{content}",).into()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
child.render_to_string_helper(
|
||||
@@ -718,12 +728,12 @@ pub(crate) fn render_serializers(
|
||||
let json = json.replace('<', "\\u003c");
|
||||
format!(
|
||||
r#"<script{nonce_str}>
|
||||
var val = {json:?};
|
||||
(function() {{ let val = {json:?};
|
||||
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
|
||||
__LEPTOS_RESOURCE_RESOLVERS.get({id})(val)
|
||||
}} else {{
|
||||
__LEPTOS_RESOLVED_RESOURCES.set({id}, val);
|
||||
}}
|
||||
}} }})();
|
||||
</script>"#,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -416,23 +416,35 @@ impl View {
|
||||
Box::new(
|
||||
move |chunks: &mut VecDeque<StreamChunk>| {
|
||||
if let Some(child) = *child {
|
||||
// On debug builds, `DynChild` has two marker nodes,
|
||||
// so there is no way for the text to be merged with
|
||||
// surrounding text when the browser parses the HTML,
|
||||
// but in release, `DynChild` only has a trailing marker,
|
||||
// and the browser automatically merges the dynamic text
|
||||
// into one single node, so we need to artificially make the
|
||||
// browser create the dynamic text as it's own text node
|
||||
if let View::Text(t) = child {
|
||||
let content = if dont_escape_text {
|
||||
// if we don't check if the string is empty,
|
||||
// the HTML is an empty string; but an empty string
|
||||
// is not a text node in HTML, so can't be updated
|
||||
// in the future. so we put a one-space text node instead
|
||||
let was_empty =
|
||||
t.content.is_empty();
|
||||
let content = if was_empty {
|
||||
" ".into()
|
||||
} else {
|
||||
t.content
|
||||
};
|
||||
// escape content unless we're in a <script> or <style>
|
||||
let content = if dont_escape_text {
|
||||
content
|
||||
} else {
|
||||
html_escape::encode_safe(
|
||||
&t.content,
|
||||
&content,
|
||||
)
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
// On debug builds, `DynChild` has two marker nodes,
|
||||
// so there is no way for the text to be merged with
|
||||
// surrounding text when the browser parses the HTML,
|
||||
// but in release, `DynChild` only has a trailing marker,
|
||||
// and the browser automatically merges the dynamic text
|
||||
// into one single node, so we need to artificially make the
|
||||
// browser create the dynamic text as it's own text node
|
||||
chunks.push_back(
|
||||
if !cfg!(debug_assertions) {
|
||||
StreamChunk::Sync(
|
||||
|
||||
@@ -33,6 +33,7 @@ log = "0.4"
|
||||
typed-builder = "0.14"
|
||||
trybuild = "1"
|
||||
leptos = { path = "../leptos" }
|
||||
insta = "1.29"
|
||||
|
||||
[features]
|
||||
csr = []
|
||||
|
||||
@@ -32,11 +32,9 @@ impl Default for Mode {
|
||||
|
||||
mod params;
|
||||
mod view;
|
||||
use template::render_template;
|
||||
use view::render_view;
|
||||
use view::{client_template::render_template, render_view};
|
||||
mod component;
|
||||
mod slot;
|
||||
mod template;
|
||||
|
||||
/// The `view` macro uses RSX (like JSX, but Rust!) It follows most of the
|
||||
/// same rules as HTML, with the following differences:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
511
leptos_macro/src/view/client_builder.rs
Normal file
511
leptos_macro/src/view/client_builder.rs
Normal file
@@ -0,0 +1,511 @@
|
||||
use super::{
|
||||
component_builder::component_to_tokens,
|
||||
expr_to_ident, fancy_class_name, fancy_style_name,
|
||||
ide_helper::IdeTagHelper,
|
||||
is_ambiguous_element, is_custom_element, is_math_ml_element,
|
||||
is_svg_element, parse_event_name,
|
||||
slot_helper::{get_slot, slot_to_tokens},
|
||||
};
|
||||
use crate::attribute_value;
|
||||
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
|
||||
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
|
||||
use quote::{quote, quote_spanned};
|
||||
use rstml::node::{KeyedAttribute, Node, NodeAttribute, NodeElement, NodeName};
|
||||
use std::collections::HashMap;
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TagType {
|
||||
Unknown,
|
||||
Html,
|
||||
Svg,
|
||||
Math,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn fragment_to_tokens(
|
||||
cx: &Ident,
|
||||
_span: Span,
|
||||
nodes: &[Node],
|
||||
lazy: bool,
|
||||
parent_type: TagType,
|
||||
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> Option<TokenStream> {
|
||||
let mut slots = HashMap::new();
|
||||
let has_slots = parent_slots.is_some();
|
||||
|
||||
let mut nodes = nodes
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
let node = node_to_tokens(
|
||||
cx,
|
||||
node,
|
||||
parent_type,
|
||||
has_slots.then_some(&mut slots),
|
||||
global_class,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Some(quote! {
|
||||
#node.into_view(#cx)
|
||||
})
|
||||
})
|
||||
.peekable();
|
||||
|
||||
if nodes.peek().is_none() {
|
||||
_ = nodes.collect::<Vec<_>>();
|
||||
if let Some(parent_slots) = parent_slots {
|
||||
for (slot, mut values) in slots.drain() {
|
||||
parent_slots
|
||||
.entry(slot)
|
||||
.and_modify(|entry| entry.append(&mut values))
|
||||
.or_insert(values);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
let tokens = if lazy {
|
||||
quote! {
|
||||
{
|
||||
::leptos::Fragment::lazy(|| [
|
||||
#(#nodes),*
|
||||
].to_vec())
|
||||
#view_marker
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
{
|
||||
::leptos::Fragment::new([
|
||||
#(#nodes),*
|
||||
].to_vec())
|
||||
#view_marker
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(parent_slots) = parent_slots {
|
||||
for (slot, mut values) in slots.drain() {
|
||||
parent_slots
|
||||
.entry(slot)
|
||||
.and_modify(|entry| entry.append(&mut values))
|
||||
.or_insert(values);
|
||||
}
|
||||
}
|
||||
|
||||
Some(tokens)
|
||||
}
|
||||
|
||||
pub(crate) fn node_to_tokens(
|
||||
cx: &Ident,
|
||||
node: &Node,
|
||||
parent_type: TagType,
|
||||
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> Option<TokenStream> {
|
||||
match node {
|
||||
Node::Fragment(fragment) => fragment_to_tokens(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
&fragment.children,
|
||||
true,
|
||||
parent_type,
|
||||
None,
|
||||
global_class,
|
||||
view_marker,
|
||||
),
|
||||
Node::Comment(_) | Node::Doctype(_) => Some(quote! {}),
|
||||
Node::Text(node) => Some(quote! {
|
||||
::leptos::leptos_dom::html::text(#node)
|
||||
}),
|
||||
Node::Block(node) => Some(quote! { #node }),
|
||||
Node::RawText(r) => {
|
||||
let text = r.to_string_best();
|
||||
let text = syn::LitStr::new(&text, r.span());
|
||||
Some(quote! { #text })
|
||||
}
|
||||
Node::Element(node) => element_to_tokens(
|
||||
cx,
|
||||
node,
|
||||
parent_type,
|
||||
parent_slots,
|
||||
global_class,
|
||||
view_marker,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn element_to_tokens(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
mut parent_type: TagType,
|
||||
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> Option<TokenStream> {
|
||||
let name = node.name();
|
||||
if is_component_node(node) {
|
||||
if let Some(slot) = get_slot(node) {
|
||||
slot_to_tokens(cx, node, slot, parent_slots, global_class);
|
||||
None
|
||||
} else {
|
||||
Some(component_to_tokens(cx, node, global_class))
|
||||
}
|
||||
} else {
|
||||
let tag = name.to_string();
|
||||
// collect close_tag name to emit semantic information for IDE.
|
||||
let mut ide_helper_close_tag = IdeTagHelper::new();
|
||||
let close_tag = node.close_tag.as_ref().map(|c| &c.name);
|
||||
let name = if is_custom_element(&tag) {
|
||||
let name = node.name().to_string();
|
||||
// link custom ident to name span for IDE docs
|
||||
let custom = Ident::new("custom", name.span());
|
||||
quote! { ::leptos::leptos_dom::html::#custom(#cx, ::leptos::leptos_dom::html::Custom::new(#name)) }
|
||||
} else if is_svg_element(&tag) {
|
||||
parent_type = TagType::Svg;
|
||||
quote! { ::leptos::leptos_dom::svg::#name(#cx) }
|
||||
} else if is_math_ml_element(&tag) {
|
||||
parent_type = TagType::Math;
|
||||
quote! { ::leptos::leptos_dom::math::#name(#cx) }
|
||||
} else if is_ambiguous_element(&tag) {
|
||||
match parent_type {
|
||||
TagType::Unknown => {
|
||||
// We decided this warning was too aggressive, but I'll leave it here in case we want it later
|
||||
/* proc_macro_error::emit_warning!(name.span(), "The view macro is assuming this is an HTML element, \
|
||||
but it is ambiguous; if it is an SVG or MathML element, prefix with svg:: or math::"); */
|
||||
quote! {
|
||||
::leptos::leptos_dom::html::#name(#cx)
|
||||
}
|
||||
}
|
||||
TagType::Html => {
|
||||
quote! { ::leptos::leptos_dom::html::#name(#cx) }
|
||||
}
|
||||
TagType::Svg => {
|
||||
quote! { ::leptos::leptos_dom::svg::#name(#cx) }
|
||||
}
|
||||
TagType::Math => {
|
||||
quote! { ::leptos::leptos_dom::math::#name(#cx) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parent_type = TagType::Html;
|
||||
quote! { ::leptos::leptos_dom::html::#name(#cx) }
|
||||
};
|
||||
|
||||
if let Some(close_tag) = close_tag {
|
||||
ide_helper_close_tag.save_tag_completion(close_tag)
|
||||
}
|
||||
|
||||
let attrs = node.attributes().iter().filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
let name = node.key.to_string();
|
||||
let name = name.trim();
|
||||
if name.starts_with("class:")
|
||||
|| fancy_class_name(name, cx, node).is_some()
|
||||
|| name.starts_with("style:")
|
||||
|| fancy_style_name(name, cx, node).is_some()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(attribute_to_tokens(cx, node, global_class))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let class_attrs = node.attributes().iter().filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
let name = node.key.to_string();
|
||||
if let Some((fancy, _, _)) = fancy_class_name(&name, cx, node) {
|
||||
Some(fancy)
|
||||
} else if name.trim().starts_with("class:") {
|
||||
Some(attribute_to_tokens(cx, node, global_class))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let style_attrs = node.attributes().iter().filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
let name = node.key.to_string();
|
||||
if let Some((fancy, _, _)) = fancy_style_name(&name, cx, node) {
|
||||
Some(fancy)
|
||||
} else if name.trim().starts_with("style:") {
|
||||
Some(attribute_to_tokens(cx, node, global_class))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let global_class_expr = match global_class {
|
||||
None => quote! {},
|
||||
Some(class) => {
|
||||
quote! {
|
||||
.classes(
|
||||
#[allow(unused_braces)]
|
||||
#class
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
let children = node.children.iter().map(|node| {
|
||||
let (child, is_static) = match node {
|
||||
Node::Fragment(fragment) => (
|
||||
fragment_to_tokens(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
&fragment.children,
|
||||
true,
|
||||
parent_type,
|
||||
None,
|
||||
global_class,
|
||||
None,
|
||||
)
|
||||
.unwrap_or({
|
||||
let span = Span::call_site();
|
||||
quote_spanned! {
|
||||
span => ::leptos::leptos_dom::Unit
|
||||
}
|
||||
}),
|
||||
false,
|
||||
),
|
||||
Node::Text(node) => (quote! { #node }, true),
|
||||
Node::RawText(node) => {
|
||||
let text = node.to_string_best();
|
||||
let text = syn::LitStr::new(&text, node.span());
|
||||
(quote! { #text }, true)
|
||||
}
|
||||
Node::Block(node) => (
|
||||
quote! {
|
||||
#node
|
||||
},
|
||||
false,
|
||||
),
|
||||
Node::Element(node) => (
|
||||
element_to_tokens(
|
||||
cx,
|
||||
node,
|
||||
parent_type,
|
||||
None,
|
||||
global_class,
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
false,
|
||||
),
|
||||
Node::Comment(_) | Node::Doctype(_) => (quote! {}, false),
|
||||
};
|
||||
if is_static {
|
||||
quote! {
|
||||
.child(#child)
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
.child((#cx, #child))
|
||||
}
|
||||
}
|
||||
});
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
let ide_helper_close_tag = ide_helper_close_tag.into_iter();
|
||||
Some(quote! {
|
||||
{
|
||||
#(#ide_helper_close_tag)*
|
||||
#name
|
||||
#(#attrs)*
|
||||
#(#class_attrs)*
|
||||
#(#style_attrs)*
|
||||
#global_class_expr
|
||||
#(#children)*
|
||||
#view_marker
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attribute_to_tokens(
|
||||
cx: &Ident,
|
||||
node: &KeyedAttribute,
|
||||
global_class: Option<&TokenTree>,
|
||||
) -> TokenStream {
|
||||
let span = node.key.span();
|
||||
let name = node.key.to_string();
|
||||
if name == "ref" || name == "_ref" || name == "ref_" || name == "node_ref" {
|
||||
let value = expr_to_ident(attribute_value(node));
|
||||
let node_ref = quote_spanned! { span => node_ref };
|
||||
|
||||
quote! {
|
||||
.#node_ref(#value)
|
||||
}
|
||||
} else if let Some(name) = name.strip_prefix("on:") {
|
||||
let handler = attribute_value(node);
|
||||
|
||||
let (event_type, is_custom, is_force_undelegated) =
|
||||
parse_event_name(name);
|
||||
|
||||
let event_name_ident = match &node.key {
|
||||
NodeName::Punctuated(parts) => {
|
||||
if parts.len() >= 2 {
|
||||
Some(&parts[1])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let undelegated_ident = match &node.key {
|
||||
NodeName::Punctuated(parts) => parts.last().and_then(|last| {
|
||||
if last.to_string() == "undelegated" {
|
||||
Some(last)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let on = match &node.key {
|
||||
NodeName::Punctuated(parts) => &parts[0],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let on = {
|
||||
let span = on.span();
|
||||
quote_spanned! {
|
||||
span => .on
|
||||
}
|
||||
};
|
||||
let event_type = if is_custom {
|
||||
event_type
|
||||
} else if let Some(ev_name) = event_name_ident {
|
||||
let span = ev_name.span();
|
||||
quote_spanned! {
|
||||
span => #ev_name
|
||||
}
|
||||
} else {
|
||||
event_type
|
||||
};
|
||||
|
||||
let event_type = if is_force_undelegated {
|
||||
let undelegated = if let Some(undelegated) = undelegated_ident {
|
||||
let span = undelegated.span();
|
||||
quote_spanned! {
|
||||
span => #undelegated
|
||||
}
|
||||
} else {
|
||||
quote! { undelegated }
|
||||
};
|
||||
quote! { ::leptos::ev::#undelegated(::leptos::ev::#event_type) }
|
||||
} else {
|
||||
quote! { ::leptos::ev::#event_type }
|
||||
};
|
||||
|
||||
quote! {
|
||||
#on(#event_type, #handler)
|
||||
}
|
||||
} else if let Some(name) = name.strip_prefix("prop:") {
|
||||
let value = attribute_value(node);
|
||||
let prop = match &node.key {
|
||||
NodeName::Punctuated(parts) => &parts[0],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let prop = {
|
||||
let span = prop.span();
|
||||
quote_spanned! {
|
||||
span => .prop
|
||||
}
|
||||
};
|
||||
quote! {
|
||||
#prop(#name, (#cx, #[allow(unused_braces)] #value))
|
||||
}
|
||||
} else if let Some(name) = name.strip_prefix("class:") {
|
||||
let value = attribute_value(node);
|
||||
let class = match &node.key {
|
||||
NodeName::Punctuated(parts) => &parts[0],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let class = {
|
||||
let span = class.span();
|
||||
quote_spanned! {
|
||||
span => .class
|
||||
}
|
||||
};
|
||||
quote! {
|
||||
#class(#name, (#cx, #[allow(unused_braces)] #value))
|
||||
}
|
||||
} else if let Some(name) = name.strip_prefix("style:") {
|
||||
let value = attribute_value(node);
|
||||
let style = match &node.key {
|
||||
NodeName::Punctuated(parts) => &parts[0],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let style = {
|
||||
let span = style.span();
|
||||
quote_spanned! {
|
||||
span => .style
|
||||
}
|
||||
};
|
||||
quote! {
|
||||
#style(#name, (#cx, #[allow(unused_braces)] #value))
|
||||
}
|
||||
} else {
|
||||
let name = name.replacen("attr:", "", 1);
|
||||
|
||||
if let Some((fancy, _, _)) = fancy_class_name(&name, cx, node) {
|
||||
return fancy;
|
||||
}
|
||||
|
||||
// special case of global_class and class attribute
|
||||
if name == "class"
|
||||
&& global_class.is_some()
|
||||
&& node.value().and_then(value_to_string).is_none()
|
||||
{
|
||||
let span = node.key.span();
|
||||
proc_macro_error::emit_error!(span, "Combining a global class (view! { cx, class = ... }) \
|
||||
and a dynamic `class=` attribute on an element causes runtime inconsistencies. You can \
|
||||
toggle individual classes dynamically with the `class:name=value` syntax. \n\nSee this issue \
|
||||
for more information and an example: https://github.com/leptos-rs/leptos/issues/773")
|
||||
};
|
||||
|
||||
// all other attributes
|
||||
let value = match node.value() {
|
||||
Some(value) => {
|
||||
quote! { #value }
|
||||
}
|
||||
None => quote_spanned! { span => "" },
|
||||
};
|
||||
|
||||
let attr = match &node.key {
|
||||
NodeName::Punctuated(parts) => Some(&parts[0]),
|
||||
_ => None,
|
||||
};
|
||||
let attr = if let Some(attr) = attr {
|
||||
let span = attr.span();
|
||||
quote_spanned! {
|
||||
span => .attr
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
.attr
|
||||
}
|
||||
};
|
||||
quote! {
|
||||
#attr(#name, (#cx, #value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{attribute_value, view::IdeTagHelper};
|
||||
use super::{component_builder::component_to_tokens, IdeTagHelper};
|
||||
use crate::attribute_value;
|
||||
use itertools::Either;
|
||||
use leptos_hot_reload::parsing::{
|
||||
block_to_primitive_expression, is_component_node, value_to_string,
|
||||
@@ -9,13 +10,10 @@ use rstml::node::{
|
||||
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement,
|
||||
};
|
||||
use syn::spanned::Spanned;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) fn render_template(cx: &Ident, nodes: &[Node]) -> TokenStream {
|
||||
let template_uid = Ident::new(
|
||||
&format!("TEMPLATE_{}", Uuid::new_v4().simple()),
|
||||
Span::call_site(),
|
||||
);
|
||||
// No reason to make template unique, because its "static" is in inner scope.
|
||||
let template_uid = Ident::new("__TEMPLATE", Span::call_site());
|
||||
|
||||
match nodes.first() {
|
||||
Some(Node::Element(node)) => {
|
||||
@@ -36,7 +34,7 @@ fn root_element_to_tokens(
|
||||
let mut expressions = Vec::new();
|
||||
|
||||
if is_component_node(node) {
|
||||
crate::view::component_to_tokens(cx, node, None)
|
||||
component_to_tokens(cx, node, None)
|
||||
} else {
|
||||
element_to_tokens(
|
||||
cx,
|
||||
@@ -65,11 +63,11 @@ fn root_element_to_tokens(
|
||||
quote! {
|
||||
{
|
||||
thread_local! {
|
||||
static #template_uid: leptos::web_sys::HtmlTemplateElement = {
|
||||
let document = leptos::document();
|
||||
static #template_uid: ::leptos::web_sys::HtmlTemplateElement = {
|
||||
let document = ::leptos::document();
|
||||
let el = document.create_element("template").unwrap();
|
||||
el.set_inner_html(#template);
|
||||
leptos::wasm_bindgen::JsCast::unchecked_into(el)
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_into(el)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +77,10 @@ fn root_element_to_tokens(
|
||||
#(#navigations)*
|
||||
#(#expressions;)*
|
||||
|
||||
leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
|
||||
::leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
|
||||
#[cfg(debug_assertions)]
|
||||
name: #tag_name.into(),
|
||||
element: leptos::wasm_bindgen::JsCast::unchecked_into(root),
|
||||
element: ::leptos::wasm_bindgen::JsCast::unchecked_into(root),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None
|
||||
})
|
||||
@@ -149,7 +147,7 @@ fn element_to_tokens(
|
||||
quote_spanned! {
|
||||
span => let #this_el_ident = #debug_name;
|
||||
let #this_el_ident =
|
||||
leptos::wasm_bindgen::JsCast::unchecked_into::<leptos::web_sys::Node>(#parent.clone());
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_into::<leptos::web_sys::Node>(#parent.clone());
|
||||
//debug!("=> got {}", #this_el_ident.node_name());
|
||||
}
|
||||
} else if let Some(prev_sib) = &prev_sib {
|
||||
@@ -302,7 +300,7 @@ fn attr_to_tokens(
|
||||
let (event_type, handler) =
|
||||
crate::view::event_from_attribute_node(node, false);
|
||||
expressions.push(quote! {
|
||||
leptos::leptos_dom::add_event_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #event_type, #handler);
|
||||
::leptos::leptos_dom::add_event_helper(::leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #event_type, #handler);
|
||||
})
|
||||
}
|
||||
// Properties
|
||||
@@ -310,7 +308,7 @@ fn attr_to_tokens(
|
||||
let value = attribute_value(node);
|
||||
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos::leptos_dom::property(#cx, leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name, #value.into_property(#cx))
|
||||
span => ::leptos::leptos_dom::property(#cx, ::leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name, #value.into_property(#cx))
|
||||
});
|
||||
}
|
||||
// Classes
|
||||
@@ -318,7 +316,7 @@ fn attr_to_tokens(
|
||||
let value = attribute_value(node);
|
||||
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos::leptos_dom::class_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), #value.into_class(#cx))
|
||||
span => ::leptos::leptos_dom::class_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), #value.into_class(#cx))
|
||||
});
|
||||
}
|
||||
// Attributes
|
||||
@@ -342,7 +340,7 @@ fn attr_to_tokens(
|
||||
// For client-side rendering, dynamic attributes don't need to be rendered in the template
|
||||
// They'll immediately be set synchronously before the cloned template is mounted
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos::leptos_dom::attribute_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), {#value}.into_attribute(#cx))
|
||||
span => ::leptos::leptos_dom::attribute_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), {#value}.into_attribute(#cx))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -493,10 +491,10 @@ fn block_to_tokens(
|
||||
|
||||
let mount_kind = match &next_sib {
|
||||
Some(child) => {
|
||||
quote! { leptos::leptos_dom::MountKind::Before(&#child.clone()) }
|
||||
quote! { ::leptos::leptos_dom::MountKind::Before(&#child.clone()) }
|
||||
}
|
||||
None => {
|
||||
quote! { leptos::leptos_dom::MountKind::Append(&#parent) }
|
||||
quote! { ::leptos::leptos_dom::MountKind::Append(&#parent) }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -516,7 +514,7 @@ fn block_to_tokens(
|
||||
navigations.push(location);
|
||||
|
||||
expressions.push(quote! {
|
||||
leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
|
||||
::leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
|
||||
});
|
||||
|
||||
if let Some(name) = name {
|
||||
176
leptos_macro/src/view/component_builder.rs
Normal file
176
leptos_macro/src/view/component_builder.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use super::{
|
||||
client_builder::{fragment_to_tokens, TagType},
|
||||
event_from_attribute_node, ident_from_tag_name,
|
||||
};
|
||||
use proc_macro2::{Ident, TokenStream, TokenTree};
|
||||
use quote::{format_ident, quote};
|
||||
use rstml::node::{NodeAttribute, NodeElement};
|
||||
use std::collections::HashMap;
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
pub(crate) fn component_to_tokens(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
global_class: Option<&TokenTree>,
|
||||
) -> TokenStream {
|
||||
let name = node.name();
|
||||
#[cfg(debug_assertions)]
|
||||
let component_name = ident_from_tag_name(node.name());
|
||||
let span = node.name().span();
|
||||
|
||||
let attrs = node.attributes().iter().filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
Some(node)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let props = attrs
|
||||
.clone()
|
||||
.filter(|attr| {
|
||||
!attr.key.to_string().starts_with("bind:")
|
||||
&& !attr.key.to_string().starts_with("clone:")
|
||||
&& !attr.key.to_string().starts_with("on:")
|
||||
})
|
||||
.map(|attr| {
|
||||
let name = &attr.key;
|
||||
|
||||
let value = attr
|
||||
.value()
|
||||
.map(|v| {
|
||||
quote! { #v }
|
||||
})
|
||||
.unwrap_or_else(|| quote! { #name });
|
||||
|
||||
quote! {
|
||||
.#name(#[allow(unused_braces)] #value)
|
||||
}
|
||||
});
|
||||
|
||||
let items_to_bind = attrs
|
||||
.clone()
|
||||
.filter_map(|attr| {
|
||||
attr.key
|
||||
.to_string()
|
||||
.strip_prefix("bind:")
|
||||
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let items_to_clone = attrs
|
||||
.clone()
|
||||
.filter_map(|attr| {
|
||||
attr.key
|
||||
.to_string()
|
||||
.strip_prefix("clone:")
|
||||
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let events = attrs
|
||||
.filter(|attr| attr.key.to_string().starts_with("on:"))
|
||||
.map(|attr| {
|
||||
let (event_type, handler) = event_from_attribute_node(attr, true);
|
||||
|
||||
quote! {
|
||||
.on(#event_type, #handler)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut slots = HashMap::new();
|
||||
let children = if node.children.is_empty() {
|
||||
quote! {}
|
||||
} else {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(debug_assertions)] {
|
||||
let marker = format!("<{component_name}/>-children");
|
||||
let view_marker = quote! { .with_view_marker(#marker) };
|
||||
} else {
|
||||
let view_marker = quote! {};
|
||||
}
|
||||
}
|
||||
|
||||
let children = fragment_to_tokens(
|
||||
cx,
|
||||
span,
|
||||
&node.children,
|
||||
true,
|
||||
TagType::Unknown,
|
||||
Some(&mut slots),
|
||||
global_class,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Some(children) = children {
|
||||
let bindables =
|
||||
items_to_bind.iter().map(|ident| quote! { #ident, });
|
||||
|
||||
let clonables = items_to_clone
|
||||
.iter()
|
||||
.map(|ident| quote! { let #ident = #ident.clone(); });
|
||||
|
||||
if bindables.len() > 0 {
|
||||
quote! {
|
||||
.children({
|
||||
#(#clonables)*
|
||||
|
||||
move |#cx, #(#bindables)*| #children #view_marker
|
||||
})
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
.children({
|
||||
#(#clonables)*
|
||||
|
||||
Box::new(move |#cx| #children #view_marker)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {}
|
||||
}
|
||||
};
|
||||
|
||||
let slots = slots.drain().map(|(slot, values)| {
|
||||
let slot = Ident::new(&slot, span);
|
||||
if values.len() > 1 {
|
||||
quote! {
|
||||
.#slot(vec![
|
||||
#(#values)*
|
||||
])
|
||||
}
|
||||
} else {
|
||||
let value = &values[0];
|
||||
quote! { .#slot(#value) }
|
||||
}
|
||||
});
|
||||
|
||||
#[allow(unused_mut)] // used in debug
|
||||
let mut component = quote! {
|
||||
::leptos::component_view(
|
||||
&#name,
|
||||
#cx,
|
||||
::leptos::component_props_builder(&#name)
|
||||
#(#props)*
|
||||
#(#slots)*
|
||||
#children
|
||||
.build()
|
||||
)
|
||||
};
|
||||
|
||||
// (Temporarily?) removed
|
||||
// See note on the function itself below.
|
||||
/* #[cfg(debug_assertions)]
|
||||
IdeTagHelper::add_component_completion(cx, &mut component, node); */
|
||||
|
||||
if events.is_empty() {
|
||||
component
|
||||
} else {
|
||||
quote! {
|
||||
#component.into_view(#cx)
|
||||
#(#events)*
|
||||
}
|
||||
}
|
||||
}
|
||||
152
leptos_macro/src/view/ide_helper.rs
Normal file
152
leptos_macro/src/view/ide_helper.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use leptos_hot_reload::parsing::is_component_tag_name;
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use quote::quote;
|
||||
use rstml::node::{NodeElement, NodeName};
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
/// Helper type to emit semantic info about tags, for IDE.
|
||||
/// Implement `IntoIterator` with `Item="let _ = foo::docs;"`.
|
||||
///
|
||||
/// `IdeTagHelper` uses warning instead of errors everywhere,
|
||||
/// it's aim is to add usability, not introduce additional typecheck in `view`/`template` code.
|
||||
/// On stable `emit_warning` don't produce anything.
|
||||
pub(crate) struct IdeTagHelper(Vec<TokenStream>);
|
||||
|
||||
// TODO: Unhandled cases:
|
||||
// - svg::div, my_elements::foo - tags with custom paths, that doesnt look like component
|
||||
// - my_component::Foo - components with custom paths
|
||||
// - html:div - tags punctuated by `:`
|
||||
// - {div}, {"div"} - any rust expression
|
||||
impl IdeTagHelper {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
/// Save stmts for tag name.
|
||||
/// Emit warning if tag is component.
|
||||
pub fn save_tag_completion(&mut self, name: &NodeName) {
|
||||
let tag_name = name.to_string();
|
||||
if is_component_tag_name(&tag_name) {
|
||||
proc_macro_error::emit_warning!(
|
||||
name.span(),
|
||||
"BUG: Component tag is used in regular tag completion."
|
||||
);
|
||||
}
|
||||
for path in Self::completion_stmts(name) {
|
||||
self.0.push(quote! {
|
||||
let _ = #path;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Save stmts for open and close tags.
|
||||
/// Emit warning if tag is component.
|
||||
pub fn save_element_completion(&mut self, node: &NodeElement) {
|
||||
self.save_tag_completion(node.name());
|
||||
if let Some(close_tag) = node.close_tag.as_ref().map(|c| &c.name) {
|
||||
self.save_tag_completion(close_tag)
|
||||
}
|
||||
}
|
||||
|
||||
/* This has been (temporarily?) removed.
|
||||
* Its purpose was simply to add syntax highlighting and IDE hints for
|
||||
* component closing tags in debug mode by associating the closing tag
|
||||
* ident with the component function.
|
||||
*
|
||||
* Doing this in a way that correctly inferred types, however, required
|
||||
* duplicating the entire component constructor.
|
||||
*
|
||||
* In view trees with many nested components, this led to a massive explosion
|
||||
* in compile times.
|
||||
*
|
||||
* See https://github.com/leptos-rs/leptos/issues/1283
|
||||
*
|
||||
/// Add completion to the closing tag of the component.
|
||||
///
|
||||
/// In order to ensure that generics are passed through correctly in the
|
||||
/// current builder pattern, this clones the whole component constructor,
|
||||
/// but it will never be used.
|
||||
///
|
||||
/// ```no_build
|
||||
/// if false {
|
||||
/// close_tag(cx, unreachable!())
|
||||
/// }
|
||||
/// else {
|
||||
/// open_tag(open_tag.props().slots().children().build())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn add_component_completion(
|
||||
cx: &Ident,
|
||||
component: &mut TokenStream,
|
||||
node: &NodeElement,
|
||||
) {
|
||||
// emit ide helper info
|
||||
if let Some(close_tag) = node.close_tag.as_ref().map(|c| &c.name) {
|
||||
*component = quote! {
|
||||
{
|
||||
let #close_tag = |cx| #component;
|
||||
#close_tag(#cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/// Returns `syn::Path`-like `TokenStream` to the fn in docs.
|
||||
/// If tag name is `Component` returns `None`.
|
||||
fn create_regular_tag_fn_path(name: &Ident) -> TokenStream {
|
||||
let tag_name = name.to_string();
|
||||
let namespace = if crate::view::is_svg_element(&tag_name) {
|
||||
quote! { ::leptos::leptos_dom::svg }
|
||||
} else if crate::view::is_math_ml_element(&tag_name) {
|
||||
quote! { ::leptos::leptos_dom::math }
|
||||
} else {
|
||||
// todo: check is html, and emit_warning in case of custom tag
|
||||
quote! { ::leptos::leptos_dom::html }
|
||||
};
|
||||
quote!( #namespace::#name)
|
||||
}
|
||||
|
||||
/// Returns `syn::Path`-like `TokenStream` to the `custom` section in docs.
|
||||
fn create_custom_tag_fn_path(span: Span) -> TokenStream {
|
||||
let custom_ident = Ident::new("custom", span);
|
||||
quote! {leptos::leptos_dom::html::#custom_ident::<leptos::leptos_dom::html::Custom>}
|
||||
}
|
||||
|
||||
// Extract from NodeName completion idents.
|
||||
// Custom tags (like foo-bar-baz) is mapped
|
||||
// to vec!["custom", "custom",.. ] for each token in tag, even for "-".
|
||||
// Only last ident from `Path` is used.
|
||||
fn completion_stmts(name: &NodeName) -> Vec<TokenStream> {
|
||||
match name {
|
||||
NodeName::Block(_) => vec![],
|
||||
NodeName::Punctuated(c) => c
|
||||
.pairs()
|
||||
.flat_map(|c| {
|
||||
let mut idents =
|
||||
vec![Self::create_custom_tag_fn_path(c.value().span())];
|
||||
if let Some(p) = c.punct() {
|
||||
idents.push(Self::create_custom_tag_fn_path(p.span()))
|
||||
}
|
||||
idents
|
||||
})
|
||||
.collect(),
|
||||
NodeName::Path(e) => e
|
||||
.path
|
||||
.segments
|
||||
.last()
|
||||
.map(|p| &p.ident)
|
||||
.map(Self::create_regular_tag_fn_path)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for IdeTagHelper {
|
||||
type Item = TokenStream;
|
||||
type IntoIter = <Vec<TokenStream> as IntoIterator>::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
548
leptos_macro/src/view/mod.rs
Normal file
548
leptos_macro/src/view/mod.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
use crate::{attribute_value, Mode};
|
||||
use convert_case::{Case::Snake, Casing};
|
||||
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
|
||||
use quote::{quote, quote_spanned};
|
||||
use rstml::node::{KeyedAttribute, Node, NodeElement, NodeName};
|
||||
use syn::{spanned::Spanned, Expr, Expr::Tuple, ExprLit, ExprPath, Lit};
|
||||
|
||||
pub mod client_builder;
|
||||
pub mod client_template;
|
||||
pub mod component_builder;
|
||||
pub mod ide_helper;
|
||||
pub mod server_template;
|
||||
pub mod slot_helper;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use ide_helper::*;
|
||||
|
||||
pub(crate) fn render_view(
|
||||
cx: &Ident,
|
||||
nodes: &[Node],
|
||||
mode: Mode,
|
||||
global_class: Option<&TokenTree>,
|
||||
call_site: Option<String>,
|
||||
) -> TokenStream {
|
||||
let empty = {
|
||||
let span = Span::call_site();
|
||||
quote_spanned! {
|
||||
span => leptos::leptos_dom::Unit
|
||||
}
|
||||
};
|
||||
|
||||
if mode == Mode::Ssr {
|
||||
match nodes.len() {
|
||||
0 => empty,
|
||||
1 => server_template::root_node_to_tokens_ssr(
|
||||
cx,
|
||||
&nodes[0],
|
||||
global_class,
|
||||
call_site,
|
||||
),
|
||||
_ => server_template::fragment_to_tokens_ssr(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
nodes,
|
||||
global_class,
|
||||
call_site,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
match nodes.len() {
|
||||
0 => empty,
|
||||
1 => client_builder::node_to_tokens(
|
||||
cx,
|
||||
&nodes[0],
|
||||
client_builder::TagType::Unknown,
|
||||
None,
|
||||
global_class,
|
||||
call_site,
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
_ => client_builder::fragment_to_tokens(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
nodes,
|
||||
true,
|
||||
client_builder::TagType::Unknown,
|
||||
None,
|
||||
global_class,
|
||||
call_site,
|
||||
)
|
||||
.unwrap_or(empty),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep list alphabetized for binary search
|
||||
const TYPED_EVENTS: [&str; 126] = [
|
||||
"DOMContentLoaded",
|
||||
"abort",
|
||||
"afterprint",
|
||||
"animationcancel",
|
||||
"animationend",
|
||||
"animationiteration",
|
||||
"animationstart",
|
||||
"auxclick",
|
||||
"beforeinput",
|
||||
"beforeprint",
|
||||
"beforeunload",
|
||||
"blur",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"change",
|
||||
"click",
|
||||
"close",
|
||||
"compositionend",
|
||||
"compositionstart",
|
||||
"compositionupdate",
|
||||
"contextmenu",
|
||||
"copy",
|
||||
"cuechange",
|
||||
"cut",
|
||||
"dblclick",
|
||||
"devicemotion",
|
||||
"deviceorientation",
|
||||
"drag",
|
||||
"dragend",
|
||||
"dragenter",
|
||||
"dragleave",
|
||||
"dragover",
|
||||
"dragstart",
|
||||
"drop",
|
||||
"durationchange",
|
||||
"emptied",
|
||||
"ended",
|
||||
"error",
|
||||
"focus",
|
||||
"focusin",
|
||||
"focusout",
|
||||
"formdata",
|
||||
"fullscreenchange",
|
||||
"fullscreenerror",
|
||||
"gamepadconnected",
|
||||
"gamepaddisconnected",
|
||||
"gotpointercapture",
|
||||
"hashchange",
|
||||
"input",
|
||||
"invalid",
|
||||
"keydown",
|
||||
"keypress",
|
||||
"keyup",
|
||||
"languagechange",
|
||||
"load",
|
||||
"loadeddata",
|
||||
"loadedmetadata",
|
||||
"loadstart",
|
||||
"lostpointercapture",
|
||||
"message",
|
||||
"messageerror",
|
||||
"mousedown",
|
||||
"mouseenter",
|
||||
"mouseleave",
|
||||
"mousemove",
|
||||
"mouseout",
|
||||
"mouseover",
|
||||
"mouseup",
|
||||
"offline",
|
||||
"online",
|
||||
"orientationchange",
|
||||
"pagehide",
|
||||
"pageshow",
|
||||
"paste",
|
||||
"pause",
|
||||
"play",
|
||||
"playing",
|
||||
"pointercancel",
|
||||
"pointerdown",
|
||||
"pointerenter",
|
||||
"pointerleave",
|
||||
"pointerlockchange",
|
||||
"pointerlockerror",
|
||||
"pointermove",
|
||||
"pointerout",
|
||||
"pointerover",
|
||||
"pointerup",
|
||||
"popstate",
|
||||
"progress",
|
||||
"ratechange",
|
||||
"readystatechange",
|
||||
"rejectionhandled",
|
||||
"reset",
|
||||
"resize",
|
||||
"scroll",
|
||||
"securitypolicyviolation",
|
||||
"seeked",
|
||||
"seeking",
|
||||
"select",
|
||||
"selectionchange",
|
||||
"selectstart",
|
||||
"slotchange",
|
||||
"stalled",
|
||||
"storage",
|
||||
"submit",
|
||||
"suspend",
|
||||
"timeupdate",
|
||||
"toggle",
|
||||
"touchcancel",
|
||||
"touchend",
|
||||
"touchmove",
|
||||
"touchstart",
|
||||
"transitioncancel",
|
||||
"transitionend",
|
||||
"transitionrun",
|
||||
"transitionstart",
|
||||
"unhandledrejection",
|
||||
"unload",
|
||||
"visibilitychange",
|
||||
"volumechange",
|
||||
"waiting",
|
||||
"webkitanimationend",
|
||||
"webkitanimationiteration",
|
||||
"webkitanimationstart",
|
||||
"webkittransitionend",
|
||||
"wheel",
|
||||
];
|
||||
|
||||
const CUSTOM_EVENT: &str = "Custom";
|
||||
|
||||
pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool) {
|
||||
let (name, is_force_undelegated) = parse_event(name);
|
||||
|
||||
let (event_type, is_custom) = TYPED_EVENTS
|
||||
.binary_search(&name)
|
||||
.map(|_| (name, false))
|
||||
.unwrap_or((CUSTOM_EVENT, true));
|
||||
|
||||
let Ok(event_type) = event_type.parse::<TokenStream>() else {
|
||||
abort!(event_type, "couldn't parse event name");
|
||||
};
|
||||
|
||||
let event_type = if is_custom {
|
||||
quote! { Custom::new(#name) }
|
||||
} else {
|
||||
event_type
|
||||
};
|
||||
(event_type, is_custom, is_force_undelegated)
|
||||
}
|
||||
|
||||
fn expr_to_ident(expr: &syn::Expr) -> Option<&ExprPath> {
|
||||
match expr {
|
||||
syn::Expr::Block(block) => block.block.stmts.last().and_then(|stmt| {
|
||||
if let syn::Stmt::Expr(expr, ..) = stmt {
|
||||
expr_to_ident(expr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
syn::Expr::Path(path) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_to_snake_case(name: String) -> String {
|
||||
if !name.is_case(Snake) {
|
||||
name.to_case(Snake)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fn is_custom_element(tag: &str) -> bool {
|
||||
tag.contains('-')
|
||||
}
|
||||
|
||||
fn is_self_closing(node: &NodeElement) -> bool {
|
||||
// self-closing tags
|
||||
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
|
||||
[
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
|
||||
"meta", "param", "source", "track", "wbr",
|
||||
]
|
||||
.binary_search(&node.name().to_string().as_str())
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn camel_case_tag_name(tag_name: &str) -> String {
|
||||
let mut chars = tag_name.chars();
|
||||
let first = chars.next();
|
||||
let underscore = if tag_name == "option" { "_" } else { "" };
|
||||
first
|
||||
.map(|f| f.to_ascii_uppercase())
|
||||
.into_iter()
|
||||
.chain(chars)
|
||||
.collect::<String>()
|
||||
+ underscore
|
||||
}
|
||||
|
||||
fn is_svg_element(tag: &str) -> bool {
|
||||
// Keep list alphabetized for binary search
|
||||
[
|
||||
"animate",
|
||||
"animateMotion",
|
||||
"animateTransform",
|
||||
"circle",
|
||||
"clipPath",
|
||||
"defs",
|
||||
"desc",
|
||||
"discard",
|
||||
"ellipse",
|
||||
"feBlend",
|
||||
"feColorMatrix",
|
||||
"feComponentTransfer",
|
||||
"feComposite",
|
||||
"feConvolveMatrix",
|
||||
"feDiffuseLighting",
|
||||
"feDisplacementMap",
|
||||
"feDistantLight",
|
||||
"feDropShadow",
|
||||
"feFlood",
|
||||
"feFuncA",
|
||||
"feFuncB",
|
||||
"feFuncG",
|
||||
"feFuncR",
|
||||
"feGaussianBlur",
|
||||
"feImage",
|
||||
"feMerge",
|
||||
"feMergeNode",
|
||||
"feMorphology",
|
||||
"feOffset",
|
||||
"fePointLight",
|
||||
"feSpecularLighting",
|
||||
"feSpotLight",
|
||||
"feTile",
|
||||
"feTurbulence",
|
||||
"filter",
|
||||
"foreignObject",
|
||||
"g",
|
||||
"hatch",
|
||||
"hatchpath",
|
||||
"image",
|
||||
"line",
|
||||
"linearGradient",
|
||||
"marker",
|
||||
"mask",
|
||||
"metadata",
|
||||
"mpath",
|
||||
"path",
|
||||
"pattern",
|
||||
"polygon",
|
||||
"polyline",
|
||||
"radialGradient",
|
||||
"rect",
|
||||
"set",
|
||||
"stop",
|
||||
"svg",
|
||||
"switch",
|
||||
"symbol",
|
||||
"text",
|
||||
"textPath",
|
||||
"tspan",
|
||||
"use",
|
||||
"use_",
|
||||
"view",
|
||||
]
|
||||
.binary_search(&tag)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn is_math_ml_element(tag: &str) -> bool {
|
||||
// Keep list alphabetized for binary search
|
||||
[
|
||||
"annotation",
|
||||
"maction",
|
||||
"math",
|
||||
"menclose",
|
||||
"merror",
|
||||
"mfenced",
|
||||
"mfrac",
|
||||
"mi",
|
||||
"mmultiscripts",
|
||||
"mn",
|
||||
"mo",
|
||||
"mover",
|
||||
"mpadded",
|
||||
"mphantom",
|
||||
"mprescripts",
|
||||
"mroot",
|
||||
"mrow",
|
||||
"ms",
|
||||
"mspace",
|
||||
"msqrt",
|
||||
"mstyle",
|
||||
"msub",
|
||||
"msubsup",
|
||||
"msup",
|
||||
"mtable",
|
||||
"mtd",
|
||||
"mtext",
|
||||
"mtr",
|
||||
"munder",
|
||||
"munderover",
|
||||
"semantics",
|
||||
]
|
||||
.binary_search(&tag)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn is_ambiguous_element(tag: &str) -> bool {
|
||||
tag == "a" || tag == "script" || tag == "title"
|
||||
}
|
||||
|
||||
fn parse_event(event_name: &str) -> (&str, bool) {
|
||||
if let Some(event_name) = event_name.strip_suffix(":undelegated") {
|
||||
(event_name, true)
|
||||
} else {
|
||||
(event_name, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn fancy_class_name<'a>(
|
||||
name: &str,
|
||||
cx: &Ident,
|
||||
node: &'a KeyedAttribute,
|
||||
) -> Option<(TokenStream, String, &'a Expr)> {
|
||||
// special case for complex class names:
|
||||
// e.g., Tailwind `class=("mt-[calc(100vh_-_3rem)]", true)`
|
||||
if name == "class" {
|
||||
if let Some(Tuple(tuple)) = node.value() {
|
||||
if tuple.elems.len() == 2 {
|
||||
let span = node.key.span();
|
||||
let class = quote_spanned! {
|
||||
span => .class
|
||||
};
|
||||
let class_name = &tuple.elems[0];
|
||||
let class_name = if let Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(s),
|
||||
..
|
||||
}) = class_name
|
||||
{
|
||||
s.value()
|
||||
} else {
|
||||
proc_macro_error::emit_error!(
|
||||
class_name.span(),
|
||||
"class name must be a string literal"
|
||||
);
|
||||
Default::default()
|
||||
};
|
||||
let value = &tuple.elems[1];
|
||||
return Some((
|
||||
quote! {
|
||||
#class(#class_name, (#cx, #value))
|
||||
},
|
||||
class_name,
|
||||
value,
|
||||
));
|
||||
} else {
|
||||
proc_macro_error::emit_error!(
|
||||
tuple.span(),
|
||||
"class tuples must have two elements."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ident_from_tag_name(tag_name: &NodeName) -> Ident {
|
||||
match tag_name {
|
||||
NodeName::Path(path) => path
|
||||
.path
|
||||
.segments
|
||||
.iter()
|
||||
.last()
|
||||
.map(|segment| segment.ident.clone())
|
||||
.expect("element needs to have a name"),
|
||||
NodeName::Block(_) => {
|
||||
let span = tag_name.span();
|
||||
proc_macro_error::emit_error!(
|
||||
span,
|
||||
"blocks not allowed in tag-name position"
|
||||
);
|
||||
Ident::new("", span)
|
||||
}
|
||||
_ => Ident::new(
|
||||
&tag_name.to_string().replace(['-', ':'], "_"),
|
||||
tag_name.span(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn fancy_style_name<'a>(
|
||||
name: &str,
|
||||
cx: &Ident,
|
||||
node: &'a KeyedAttribute,
|
||||
) -> Option<(TokenStream, String, &'a Expr)> {
|
||||
// special case for complex dynamic style names:
|
||||
if name == "style" {
|
||||
if let Some(Tuple(tuple)) = node.value() {
|
||||
if tuple.elems.len() == 2 {
|
||||
let span = node.key.span();
|
||||
let style = quote_spanned! {
|
||||
span => .style
|
||||
};
|
||||
let style_name = &tuple.elems[0];
|
||||
let style_name = if let Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(s),
|
||||
..
|
||||
}) = style_name
|
||||
{
|
||||
s.value()
|
||||
} else {
|
||||
proc_macro_error::emit_error!(
|
||||
style_name.span(),
|
||||
"style name must be a string literal"
|
||||
);
|
||||
Default::default()
|
||||
};
|
||||
let value = &tuple.elems[1];
|
||||
return Some((
|
||||
quote! {
|
||||
#style(#style_name, (#cx, #value))
|
||||
},
|
||||
style_name,
|
||||
value,
|
||||
));
|
||||
} else {
|
||||
proc_macro_error::emit_error!(
|
||||
tuple.span(),
|
||||
"style tuples must have two elements."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn event_from_attribute_node(
|
||||
attr: &KeyedAttribute,
|
||||
force_undelegated: bool,
|
||||
) -> (TokenStream, &Expr) {
|
||||
let event_name = attr
|
||||
.key
|
||||
.to_string()
|
||||
.strip_prefix("on:")
|
||||
.expect("expected `on:` directive")
|
||||
.to_owned();
|
||||
|
||||
let handler = attribute_value(attr);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let (name, name_undelegated) = parse_event(&event_name);
|
||||
|
||||
let event_type = TYPED_EVENTS
|
||||
.binary_search(&name)
|
||||
.map(|_| (name))
|
||||
.unwrap_or(CUSTOM_EVENT);
|
||||
|
||||
let Ok(event_type) = event_type.parse::<TokenStream>() else {
|
||||
abort!(attr.key, "couldn't parse event name");
|
||||
};
|
||||
|
||||
let event_type = if force_undelegated || name_undelegated {
|
||||
quote! { ::leptos::leptos_dom::ev::undelegated(::leptos::leptos_dom::ev::#event_type) }
|
||||
} else {
|
||||
quote! { ::leptos::leptos_dom::ev::#event_type }
|
||||
};
|
||||
(event_type, handler)
|
||||
}
|
||||
707
leptos_macro/src/view/server_template.rs
Normal file
707
leptos_macro/src/view/server_template.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
use super::{
|
||||
camel_case_tag_name,
|
||||
component_builder::component_to_tokens,
|
||||
fancy_class_name, fancy_style_name,
|
||||
ide_helper::IdeTagHelper,
|
||||
is_custom_element, is_math_ml_element, is_self_closing, is_svg_element,
|
||||
parse_event_name,
|
||||
slot_helper::{get_slot, slot_to_tokens},
|
||||
};
|
||||
use crate::attribute_value;
|
||||
use leptos_hot_reload::parsing::{
|
||||
block_to_primitive_expression, is_component_node, value_to_string,
|
||||
};
|
||||
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
|
||||
use quote::quote;
|
||||
use rstml::node::{
|
||||
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
pub(crate) enum SsrElementChunks {
|
||||
String {
|
||||
template: String,
|
||||
holes: Vec<TokenStream>,
|
||||
},
|
||||
View(TokenStream),
|
||||
}
|
||||
|
||||
pub(crate) fn root_node_to_tokens_ssr(
|
||||
cx: &Ident,
|
||||
node: &Node,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
match node {
|
||||
Node::Fragment(fragment) => fragment_to_tokens_ssr(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
&fragment.children,
|
||||
global_class,
|
||||
view_marker,
|
||||
),
|
||||
Node::Comment(_) | Node::Doctype(_) => quote! {},
|
||||
Node::Text(node) => {
|
||||
quote! {
|
||||
leptos::leptos_dom::html::text(#node)
|
||||
}
|
||||
}
|
||||
Node::RawText(r) => {
|
||||
let text = r.to_string_best();
|
||||
let text = syn::LitStr::new(&text, r.span());
|
||||
quote! {
|
||||
leptos::leptos_dom::html::text(#text)
|
||||
}
|
||||
}
|
||||
Node::Block(node) => {
|
||||
quote! {
|
||||
#node
|
||||
}
|
||||
}
|
||||
Node::Element(node) => {
|
||||
root_element_to_tokens_ssr(cx, node, global_class, view_marker)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fragment_to_tokens_ssr(
|
||||
cx: &Ident,
|
||||
_span: Span,
|
||||
nodes: &[Node],
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
let nodes = nodes.iter().map(|node| {
|
||||
let node = root_node_to_tokens_ssr(cx, node, global_class, None);
|
||||
quote! {
|
||||
#node.into_view(#cx)
|
||||
}
|
||||
});
|
||||
quote! {
|
||||
{
|
||||
leptos::Fragment::lazy(|| [
|
||||
#(#nodes),*
|
||||
].to_vec())
|
||||
#view_marker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn root_element_to_tokens_ssr(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> Option<TokenStream> {
|
||||
// TODO: simplify, this is checked twice, second time in `element_to_tokens_ssr` body
|
||||
if is_component_node(node) {
|
||||
if let Some(slot) = get_slot(node) {
|
||||
slot_to_tokens(cx, node, slot, None, global_class);
|
||||
None
|
||||
} else {
|
||||
Some(component_to_tokens(cx, node, global_class))
|
||||
}
|
||||
} else {
|
||||
let mut stmts_for_ide = IdeTagHelper::new();
|
||||
let mut exprs_for_compiler = Vec::<TokenStream>::new();
|
||||
|
||||
let mut template = String::new();
|
||||
let mut holes = Vec::new();
|
||||
let mut chunks = Vec::new();
|
||||
element_to_tokens_ssr(
|
||||
cx,
|
||||
node,
|
||||
None,
|
||||
&mut template,
|
||||
&mut holes,
|
||||
&mut chunks,
|
||||
&mut stmts_for_ide,
|
||||
&mut exprs_for_compiler,
|
||||
true,
|
||||
global_class,
|
||||
);
|
||||
|
||||
// push final chunk
|
||||
if !template.is_empty() {
|
||||
chunks.push(SsrElementChunks::String { template, holes })
|
||||
}
|
||||
|
||||
let chunks = chunks.into_iter().map(|chunk| match chunk {
|
||||
SsrElementChunks::String { template, holes } => {
|
||||
if holes.is_empty() {
|
||||
let template = template.replace("\\{", "{").replace("\\}", "}");
|
||||
quote! {
|
||||
leptos::leptos_dom::html::StringOrView::String(#template.into())
|
||||
}
|
||||
} else {
|
||||
let template = template.replace("\\{", "{{").replace("\\}", "}}");
|
||||
quote! {
|
||||
leptos::leptos_dom::html::StringOrView::String(
|
||||
format!(
|
||||
#template,
|
||||
#(#holes),*
|
||||
)
|
||||
.into()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SsrElementChunks::View(view) => {
|
||||
quote! {
|
||||
#[allow(unused_braces)]
|
||||
{
|
||||
let view = #view;
|
||||
leptos::leptos_dom::html::StringOrView::View(std::rc::Rc::new(move || view.clone()))
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let tag_name = node.name().to_string();
|
||||
let is_custom_element = is_custom_element(&tag_name);
|
||||
|
||||
// Use any other span instead of node.name.span(), to avoid misunderstanding in IDE.
|
||||
// We can use open_tag.span(), to provide similar (to name span) diagnostic
|
||||
// in case of expansion error, but it will also highlight "<" token.
|
||||
let typed_element_name = if is_custom_element {
|
||||
Ident::new("Custom", Span::call_site())
|
||||
} else {
|
||||
let camel_cased = camel_case_tag_name(
|
||||
tag_name
|
||||
.trim_start_matches("svg::")
|
||||
.trim_start_matches("math::")
|
||||
.trim_end_matches('_'),
|
||||
);
|
||||
Ident::new(&camel_cased, Span::call_site())
|
||||
};
|
||||
let typed_element_name = if is_svg_element(&tag_name) {
|
||||
quote! { svg::#typed_element_name }
|
||||
} else if is_math_ml_element(&tag_name) {
|
||||
quote! { math::#typed_element_name }
|
||||
} else {
|
||||
quote! { html::#typed_element_name }
|
||||
};
|
||||
let full_name = if is_custom_element {
|
||||
quote! {
|
||||
::leptos::leptos_dom::html::Custom::new(#tag_name)
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
::leptos::leptos_dom::#typed_element_name::default()
|
||||
}
|
||||
};
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
let stmts_for_ide = stmts_for_ide.into_iter();
|
||||
Some(quote! {
|
||||
{
|
||||
#(#stmts_for_ide)*
|
||||
#(#exprs_for_compiler)*
|
||||
::leptos::HtmlElement::from_chunks(#cx, #full_name, [#(#chunks),*])#view_marker
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn element_to_tokens_ssr(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
|
||||
template: &mut String,
|
||||
holes: &mut Vec<TokenStream>,
|
||||
chunks: &mut Vec<SsrElementChunks>,
|
||||
stmts_for_ide: &mut IdeTagHelper,
|
||||
exprs_for_compiler: &mut Vec<TokenStream>,
|
||||
is_root: bool,
|
||||
global_class: Option<&TokenTree>,
|
||||
) {
|
||||
if is_component_node(node) {
|
||||
if let Some(slot) = get_slot(node) {
|
||||
slot_to_tokens(cx, node, slot, parent_slots, global_class);
|
||||
return;
|
||||
}
|
||||
|
||||
let component = component_to_tokens(cx, node, global_class);
|
||||
|
||||
if !template.is_empty() {
|
||||
chunks.push(SsrElementChunks::String {
|
||||
template: std::mem::take(template),
|
||||
holes: std::mem::take(holes),
|
||||
})
|
||||
}
|
||||
|
||||
chunks.push(SsrElementChunks::View(quote! {
|
||||
{#component}.into_view(#cx)
|
||||
}));
|
||||
} else {
|
||||
let tag_name = node.name().to_string();
|
||||
let tag_name = tag_name
|
||||
.trim_start_matches("svg::")
|
||||
.trim_start_matches("math::")
|
||||
.trim_end_matches('_');
|
||||
let is_script_or_style = tag_name == "script" || tag_name == "style";
|
||||
template.push('<');
|
||||
template.push_str(tag_name);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
stmts_for_ide.save_element_completion(node);
|
||||
|
||||
let mut inner_html = None;
|
||||
|
||||
for attr in node.attributes() {
|
||||
if let NodeAttribute::Attribute(attr) = attr {
|
||||
inner_html = attribute_to_tokens_ssr(
|
||||
cx,
|
||||
attr,
|
||||
template,
|
||||
holes,
|
||||
exprs_for_compiler,
|
||||
global_class,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// insert hydration ID
|
||||
let hydration_id = if is_root {
|
||||
quote! { ::leptos::leptos_dom::HydrationCtx::peek() }
|
||||
} else {
|
||||
quote! { ::leptos::leptos_dom::HydrationCtx::id() }
|
||||
};
|
||||
match node
|
||||
.attributes()
|
||||
.iter()
|
||||
.find(|node| matches!(node, NodeAttribute::Attribute(attr) if attr.key.to_string() == "id"))
|
||||
{
|
||||
Some(_) => {
|
||||
template.push_str(" leptos-hk=\"_{}\"");
|
||||
}
|
||||
None => {
|
||||
template.push_str(" id=\"_{}\"");
|
||||
}
|
||||
}
|
||||
holes.push(hydration_id);
|
||||
|
||||
set_class_attribute_ssr(cx, node, template, holes, global_class);
|
||||
set_style_attribute_ssr(cx, node, template, holes);
|
||||
|
||||
if is_self_closing(node) {
|
||||
template.push_str("/>");
|
||||
} else {
|
||||
template.push('>');
|
||||
|
||||
if let Some(inner_html) = inner_html {
|
||||
template.push_str("{}");
|
||||
let value = inner_html;
|
||||
|
||||
holes.push(quote! {
|
||||
(#value).into_attribute(#cx).as_nameless_value_string().unwrap_or_default()
|
||||
})
|
||||
} else {
|
||||
for child in &node.children {
|
||||
match child {
|
||||
Node::Element(child) => {
|
||||
element_to_tokens_ssr(
|
||||
cx,
|
||||
child,
|
||||
None,
|
||||
template,
|
||||
holes,
|
||||
chunks,
|
||||
stmts_for_ide,
|
||||
exprs_for_compiler,
|
||||
false,
|
||||
global_class,
|
||||
);
|
||||
}
|
||||
Node::Text(text) => {
|
||||
let value = text.value_string();
|
||||
let value = if is_script_or_style {
|
||||
value.into()
|
||||
} else {
|
||||
html_escape::encode_safe(&value)
|
||||
};
|
||||
template.push_str(
|
||||
&value.replace('{', "\\{").replace('}', "\\}"),
|
||||
);
|
||||
}
|
||||
Node::RawText(r) => {
|
||||
let value = r.to_string_best();
|
||||
let value = if is_script_or_style {
|
||||
value.into()
|
||||
} else {
|
||||
html_escape::encode_safe(&value)
|
||||
};
|
||||
template.push_str(
|
||||
&value.replace('{', "\\{").replace('}', "\\}"),
|
||||
);
|
||||
}
|
||||
Node::Block(NodeBlock::ValidBlock(block)) => {
|
||||
if let Some(value) =
|
||||
block_to_primitive_expression(block)
|
||||
.and_then(value_to_string)
|
||||
{
|
||||
template.push_str(&value);
|
||||
} else {
|
||||
if !template.is_empty() {
|
||||
chunks.push(SsrElementChunks::String {
|
||||
template: std::mem::take(template),
|
||||
holes: std::mem::take(holes),
|
||||
})
|
||||
}
|
||||
chunks.push(SsrElementChunks::View(quote! {
|
||||
{#block}.into_view(#cx)
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Keep invalid blocks for faster IDE diff (on user type)
|
||||
Node::Block(block @ NodeBlock::Invalid { .. }) => {
|
||||
chunks.push(SsrElementChunks::View(quote! {
|
||||
{#block}.into_view(#cx)
|
||||
}));
|
||||
}
|
||||
Node::Fragment(_) => abort!(
|
||||
Span::call_site(),
|
||||
"You can't nest a fragment inside an element."
|
||||
),
|
||||
Node::Comment(_) | Node::Doctype(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
template.push_str("</");
|
||||
template.push_str(tag_name);
|
||||
template.push('>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// returns `inner_html`
|
||||
fn attribute_to_tokens_ssr<'a>(
|
||||
cx: &Ident,
|
||||
attr: &'a KeyedAttribute,
|
||||
template: &mut String,
|
||||
holes: &mut Vec<TokenStream>,
|
||||
exprs_for_compiler: &mut Vec<TokenStream>,
|
||||
global_class: Option<&TokenTree>,
|
||||
) -> Option<&'a syn::Expr> {
|
||||
let name = attr.key.to_string();
|
||||
if name == "ref" || name == "_ref" || name == "ref_" || name == "node_ref" {
|
||||
// ignore refs on SSR
|
||||
} else if let Some(name) = name.strip_prefix("on:") {
|
||||
let handler = attribute_value(attr);
|
||||
let (event_type, _, _) = parse_event_name(name);
|
||||
|
||||
exprs_for_compiler.push(quote! {
|
||||
leptos::leptos_dom::helpers::ssr_event_listener(::leptos::ev::#event_type, #handler);
|
||||
})
|
||||
} else if name.strip_prefix("prop:").is_some()
|
||||
|| name.strip_prefix("class:").is_some()
|
||||
|| name.strip_prefix("style:").is_some()
|
||||
{
|
||||
// ignore props for SSR
|
||||
// ignore classes and sdtyles: we'll handle these separately
|
||||
if name.starts_with("prop:") {
|
||||
let value = attr.value();
|
||||
exprs_for_compiler.push(quote! {
|
||||
#[allow(unused_braces)]
|
||||
{ _ = #value; }
|
||||
});
|
||||
}
|
||||
} else if name == "inner_html" {
|
||||
return attr.value();
|
||||
} else {
|
||||
let name = name.replacen("attr:", "", 1);
|
||||
|
||||
// special case of global_class and class attribute
|
||||
if name == "class"
|
||||
&& global_class.is_some()
|
||||
&& attr.value().and_then(value_to_string).is_none()
|
||||
{
|
||||
let span = attr.key.span();
|
||||
proc_macro_error::emit_error!(span, "Combining a global class (view! { cx, class = ... }) \
|
||||
and a dynamic `class=` attribute on an element causes runtime inconsistencies. You can \
|
||||
toggle individual classes dynamically with the `class:name=value` syntax. \n\nSee this issue \
|
||||
for more information and an example: https://github.com/leptos-rs/leptos/issues/773")
|
||||
};
|
||||
|
||||
if name != "class" && name != "style" {
|
||||
template.push(' ');
|
||||
|
||||
if let Some(value) = attr.value() {
|
||||
if let Some(value) = value_to_string(value) {
|
||||
template.push_str(&name);
|
||||
template.push_str("=\"");
|
||||
template.push_str(&html_escape::encode_quoted_attribute(
|
||||
&value,
|
||||
));
|
||||
template.push('"');
|
||||
} else {
|
||||
template.push_str("{}");
|
||||
holes.push(quote! {
|
||||
&{#value}.into_attribute(#cx)
|
||||
.as_nameless_value_string()
|
||||
.map(|a| format!("{}=\"{}\"", #name, leptos::leptos_dom::ssr::escape_attr(&a)))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
template.push_str(&name);
|
||||
}
|
||||
}
|
||||
};
|
||||
None
|
||||
}
|
||||
|
||||
fn set_class_attribute_ssr(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
template: &mut String,
|
||||
holes: &mut Vec<TokenStream>,
|
||||
global_class: Option<&TokenTree>,
|
||||
) {
|
||||
let (static_global_class, dyn_global_class) = match global_class {
|
||||
Some(TokenTree::Literal(lit)) => {
|
||||
let str = lit.to_string();
|
||||
// A lit here can be a string, byte_string, char, byte_char, int or float.
|
||||
// If it's a string we remove the quotes so folks can use them directly
|
||||
// without needing braces. E.g. view!{cx, class="my-class", ... }
|
||||
let str = if str.starts_with('"') && str.ends_with('"') {
|
||||
str[1..str.len() - 1].to_string()
|
||||
} else {
|
||||
str
|
||||
};
|
||||
(str, None)
|
||||
}
|
||||
None => (String::new(), None),
|
||||
Some(val) => (String::new(), Some(val)),
|
||||
};
|
||||
let static_class_attr = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
NodeAttribute::Attribute(attr)
|
||||
if attr.key.to_string() == "class" =>
|
||||
{
|
||||
attr.value().and_then(value_to_string)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.chain(Some(static_global_class))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let dyn_class_attr = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
if let NodeAttribute::Attribute(a) = a {
|
||||
if a.key.to_string() == "class" {
|
||||
if a.value().and_then(value_to_string).is_some()
|
||||
|| fancy_class_name(&a.key.to_string(), cx, a).is_some()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some((a.key.span(), a.value()))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let class_attrs = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
let name = node.key.to_string();
|
||||
if name == "class" {
|
||||
return if let Some((_, name, value)) =
|
||||
fancy_class_name(&name, cx, node)
|
||||
{
|
||||
let span = node.key.span();
|
||||
Some((span, name, value))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if name.starts_with("class:") || name.starts_with("class-") {
|
||||
let name = if name.starts_with("class:") {
|
||||
name.replacen("class:", "", 1)
|
||||
} else if name.starts_with("class-") {
|
||||
name.replacen("class-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = attribute_value(node);
|
||||
let span = node.key.span();
|
||||
Some((span, name, value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !static_class_attr.is_empty()
|
||||
|| !dyn_class_attr.is_empty()
|
||||
|| !class_attrs.is_empty()
|
||||
|| dyn_global_class.is_some()
|
||||
{
|
||||
template.push_str(" class=\"");
|
||||
|
||||
template.push_str(&html_escape::encode_quoted_attribute(
|
||||
&static_class_attr,
|
||||
));
|
||||
|
||||
for (_span, value) in dyn_class_attr {
|
||||
if let Some(value) = value {
|
||||
template.push_str(" {}");
|
||||
holes.push(quote! {
|
||||
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
|
||||
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (_span, name, value) in &class_attrs {
|
||||
template.push_str(" {}");
|
||||
holes.push(quote! {
|
||||
(#cx, #value).into_class(#cx).as_value_string(#name)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(dyn_global_class) = dyn_global_class {
|
||||
template.push_str(" {}");
|
||||
holes.push(quote! { #dyn_global_class });
|
||||
}
|
||||
|
||||
template.push('"');
|
||||
}
|
||||
}
|
||||
|
||||
fn set_style_attribute_ssr(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
template: &mut String,
|
||||
holes: &mut Vec<TokenStream>,
|
||||
) {
|
||||
let static_style_attr = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
NodeAttribute::Attribute(attr)
|
||||
if attr.key.to_string() == "style" =>
|
||||
{
|
||||
attr.value().and_then(value_to_string)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
.map(|style| format!("{style};"));
|
||||
|
||||
let dyn_style_attr = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
if let NodeAttribute::Attribute(a) = a {
|
||||
if a.key.to_string() == "style" {
|
||||
if a.value().and_then(value_to_string).is_some()
|
||||
|| fancy_style_name(&a.key.to_string(), cx, a).is_some()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some((a.key.span(), a.value()))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let style_attrs = node
|
||||
.attributes()
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
let name = node.key.to_string();
|
||||
if name == "style" {
|
||||
return if let Some((_, name, value)) =
|
||||
fancy_style_name(&name, cx, node)
|
||||
{
|
||||
let span = node.key.span();
|
||||
Some((span, name, value))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if name.starts_with("style:") || name.starts_with("style-") {
|
||||
let name = if name.starts_with("style:") {
|
||||
name.replacen("style:", "", 1)
|
||||
} else if name.starts_with("style-") {
|
||||
name.replacen("style-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = attribute_value(node);
|
||||
let span = node.key.span();
|
||||
Some((span, name, value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if static_style_attr.is_some()
|
||||
|| !dyn_style_attr.is_empty()
|
||||
|| !style_attrs.is_empty()
|
||||
{
|
||||
template.push_str(" style=\"");
|
||||
|
||||
template.push_str(&static_style_attr.unwrap_or_default());
|
||||
|
||||
for (_span, value) in dyn_style_attr {
|
||||
if let Some(value) = value {
|
||||
template.push_str(" {};");
|
||||
holes.push(quote! {
|
||||
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
|
||||
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (_span, name, value) in &style_attrs {
|
||||
template.push_str(" {}");
|
||||
holes.push(quote! {
|
||||
(#cx, #value).into_style(#cx).as_value_string(#name).unwrap_or_default()
|
||||
});
|
||||
}
|
||||
|
||||
template.push('"');
|
||||
}
|
||||
}
|
||||
191
leptos_macro/src/view/slot_helper.rs
Normal file
191
leptos_macro/src/view/slot_helper.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use super::{
|
||||
client_builder::{fragment_to_tokens, TagType},
|
||||
convert_to_snake_case, ident_from_tag_name,
|
||||
};
|
||||
use proc_macro2::{Ident, TokenStream, TokenTree};
|
||||
use quote::{format_ident, quote};
|
||||
use rstml::node::{KeyedAttribute, NodeAttribute, NodeElement};
|
||||
use std::collections::HashMap;
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
pub(crate) fn slot_to_tokens(
|
||||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
slot: &KeyedAttribute,
|
||||
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
|
||||
global_class: Option<&TokenTree>,
|
||||
) {
|
||||
let name = slot.key.to_string();
|
||||
let name = name.trim();
|
||||
let name = convert_to_snake_case(if name.starts_with("slot:") {
|
||||
name.replacen("slot:", "", 1)
|
||||
} else {
|
||||
node.name().to_string()
|
||||
});
|
||||
|
||||
let component_name = ident_from_tag_name(node.name());
|
||||
let span = node.name().span();
|
||||
|
||||
let Some(parent_slots) = parent_slots else {
|
||||
proc_macro_error::emit_error!(
|
||||
span,
|
||||
"slots cannot be used inside HTML elements"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let attrs = node.attributes().iter().filter_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
if is_slot(node) {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let props = attrs
|
||||
.clone()
|
||||
.filter(|attr| {
|
||||
!attr.key.to_string().starts_with("bind:")
|
||||
&& !attr.key.to_string().starts_with("clone:")
|
||||
})
|
||||
.map(|attr| {
|
||||
let name = &attr.key;
|
||||
|
||||
let value = attr
|
||||
.value()
|
||||
.map(|v| {
|
||||
quote! { #v }
|
||||
})
|
||||
.unwrap_or_else(|| quote! { #name });
|
||||
|
||||
quote! {
|
||||
.#name(#[allow(unused_braces)] #value)
|
||||
}
|
||||
});
|
||||
|
||||
let items_to_bind = attrs
|
||||
.clone()
|
||||
.filter_map(|attr| {
|
||||
attr.key
|
||||
.to_string()
|
||||
.strip_prefix("bind:")
|
||||
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let items_to_clone = attrs
|
||||
.clone()
|
||||
.filter_map(|attr| {
|
||||
attr.key
|
||||
.to_string()
|
||||
.strip_prefix("clone:")
|
||||
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut slots = HashMap::new();
|
||||
let children = if node.children.is_empty() {
|
||||
quote! {}
|
||||
} else {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(debug_assertions)] {
|
||||
let marker = format!("<{component_name}/>-children");
|
||||
let view_marker = quote! { .with_view_marker(#marker) };
|
||||
} else {
|
||||
let view_marker = quote! {};
|
||||
}
|
||||
}
|
||||
|
||||
let children = fragment_to_tokens(
|
||||
cx,
|
||||
span,
|
||||
&node.children,
|
||||
true,
|
||||
TagType::Unknown,
|
||||
Some(&mut slots),
|
||||
global_class,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Some(children) = children {
|
||||
let bindables =
|
||||
items_to_bind.iter().map(|ident| quote! { #ident, });
|
||||
|
||||
let clonables = items_to_clone
|
||||
.iter()
|
||||
.map(|ident| quote! { let #ident = #ident.clone(); });
|
||||
|
||||
if bindables.len() > 0 {
|
||||
quote! {
|
||||
.children({
|
||||
#(#clonables)*
|
||||
|
||||
move |#cx, #(#bindables)*| #children #view_marker
|
||||
})
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
.children({
|
||||
#(#clonables)*
|
||||
|
||||
Box::new(move |#cx| #children #view_marker)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {}
|
||||
}
|
||||
};
|
||||
|
||||
let slots = slots.drain().map(|(slot, values)| {
|
||||
let slot = Ident::new(&slot, span);
|
||||
if values.len() > 1 {
|
||||
quote! {
|
||||
.#slot([
|
||||
#(#values)*
|
||||
].to_vec())
|
||||
}
|
||||
} else {
|
||||
let value = &values[0];
|
||||
quote! { .#slot(#value) }
|
||||
}
|
||||
});
|
||||
|
||||
let slot = quote! {
|
||||
#component_name::builder()
|
||||
#(#props)*
|
||||
#(#slots)*
|
||||
#children
|
||||
.build()
|
||||
.into(),
|
||||
};
|
||||
|
||||
parent_slots
|
||||
.entry(name)
|
||||
.and_modify(|entry| entry.push(slot.clone()))
|
||||
.or_insert(vec![slot]);
|
||||
}
|
||||
|
||||
pub(crate) fn is_slot(node: &KeyedAttribute) -> bool {
|
||||
let key = node.key.to_string();
|
||||
let key = key.trim();
|
||||
key == "slot" || key.starts_with("slot:")
|
||||
}
|
||||
|
||||
pub(crate) fn get_slot(node: &NodeElement) -> Option<&KeyedAttribute> {
|
||||
node.attributes().iter().find_map(|node| {
|
||||
if let NodeAttribute::Attribute(node) = node {
|
||||
if is_slot(node) {
|
||||
Some(node)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
::leptos::component_view(
|
||||
&SimpleCounter,
|
||||
cx,
|
||||
::leptos::component_props_builder(&SimpleCounter)
|
||||
.initial_value(#[allow(unused_braces)] 0)
|
||||
.step(#[allow(unused_braces)] 1)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: result
|
||||
---
|
||||
TokenStream [
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_view,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: cx,
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_props_builder,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: initial_value,
|
||||
span: bytes(37..50),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 0,
|
||||
span: bytes(51..52),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: step,
|
||||
span: bytes(65..69),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 1,
|
||||
span: bytes(70..71),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: build,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
{
|
||||
thread_local! {
|
||||
static __TEMPLATE : ::leptos::web_sys::HtmlTemplateElement = { let document =
|
||||
::leptos::document(); let el = document.create_element("template").unwrap();
|
||||
el
|
||||
.set_inner_html("<div><button>Clear</button><button>-1</button><span>Value: <!>!</span><button>+1</button></div>");
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_into(el) }
|
||||
}
|
||||
let _ = ::leptos::leptos_dom::html::div;
|
||||
let _ = ::leptos::leptos_dom::html::div;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::span;
|
||||
let _ = ::leptos::leptos_dom::html::span;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let root = __TEMPLATE
|
||||
.with(|tpl| tpl.content().clone_node_with_deep(true))
|
||||
.unwrap()
|
||||
.first_child()
|
||||
.unwrap();
|
||||
let _el1 = "div";
|
||||
let _el1 = ::leptos::wasm_bindgen::JsCast::unchecked_into::<
|
||||
leptos::web_sys::Node,
|
||||
>(root.clone());
|
||||
let _el2 = "button";
|
||||
let _el2 = _el1
|
||||
.first_child()
|
||||
.unwrap_or_else(|| panic!("error: {} => {}", "button", "firstChild"));
|
||||
let _el3 = _el2
|
||||
.first_child()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
|
||||
let _el4 = "button";
|
||||
let _el4 = _el2
|
||||
.next_sibling()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "button", "nextSibling"));
|
||||
let _el5 = _el4
|
||||
.first_child()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
|
||||
let _el6 = "span";
|
||||
let _el6 = _el4
|
||||
.next_sibling()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "span", "nextSibling"));
|
||||
let _el7 = _el6
|
||||
.first_child()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
|
||||
let _el8 = _el7
|
||||
.next_sibling()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "nextSibling"));
|
||||
let _el9 = _el8
|
||||
.next_sibling()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "nextSibling"));
|
||||
let _el10 = "button";
|
||||
let _el10 = _el6
|
||||
.next_sibling()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "button", "nextSibling"));
|
||||
let _el11 = _el10
|
||||
.first_child()
|
||||
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
|
||||
::leptos::leptos_dom::add_event_helper(
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el2),
|
||||
::leptos::leptos_dom::ev::click,
|
||||
move |_| set_value(0),
|
||||
);
|
||||
::leptos::leptos_dom::add_event_helper(
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el4),
|
||||
::leptos::leptos_dom::ev::click,
|
||||
move |_| set_value.update(|value| *value -= step),
|
||||
);
|
||||
::leptos::leptos_dom::mount_child(
|
||||
::leptos::leptos_dom::MountKind::Before(&_el8.clone()),
|
||||
&{ { value } }.into_view(cx),
|
||||
);
|
||||
::leptos::leptos_dom::add_event_helper(
|
||||
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el10),
|
||||
::leptos::leptos_dom::ev::click,
|
||||
move |_| set_value.update(|value| *value += step),
|
||||
);
|
||||
::leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
|
||||
#[cfg(debug_assertions)]
|
||||
name: "div".into(),
|
||||
element: ::leptos::wasm_bindgen::JsCast::unchecked_into(root),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
::leptos::component_view(
|
||||
&SimpleCounter,
|
||||
cx,
|
||||
::leptos::component_props_builder(&SimpleCounter)
|
||||
.initial_value(#[allow(unused_braces)] 0)
|
||||
.step(#[allow(unused_braces)] 1)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: result
|
||||
---
|
||||
TokenStream [
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_view,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: cx,
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_props_builder,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: initial_value,
|
||||
span: bytes(37..50),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 0,
|
||||
span: bytes(51..52),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: step,
|
||||
span: bytes(65..69),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 1,
|
||||
span: bytes(70..71),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: build,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::div;
|
||||
::leptos::leptos_dom::html::div(cx)
|
||||
.child((
|
||||
cx,
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
::leptos::leptos_dom::html::button(cx)
|
||||
.on(::leptos::ev::click, move |_| set_value(0))
|
||||
.child("Clear")
|
||||
},
|
||||
))
|
||||
.child((
|
||||
cx,
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
::leptos::leptos_dom::html::button(cx)
|
||||
.on(
|
||||
::leptos::ev::click,
|
||||
move |_| set_value.update(|value| *value -= step),
|
||||
)
|
||||
.child("-1")
|
||||
},
|
||||
))
|
||||
.child((
|
||||
cx,
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::span;
|
||||
::leptos::leptos_dom::html::span(cx)
|
||||
.child("Value: ")
|
||||
.child((cx, { value }))
|
||||
.child("!")
|
||||
},
|
||||
))
|
||||
.child((
|
||||
cx,
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
::leptos::leptos_dom::html::button(cx)
|
||||
.on(
|
||||
::leptos::ev::click,
|
||||
move |_| set_value.update(|value| *value += step),
|
||||
)
|
||||
.child("+1")
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
::leptos::component_view(
|
||||
&SimpleCounter,
|
||||
cx,
|
||||
::leptos::component_props_builder(&SimpleCounter)
|
||||
.initial_value(#[allow(unused_braces)] 0)
|
||||
.step(#[allow(unused_braces)] 1)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: result
|
||||
---
|
||||
TokenStream [
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_view,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: cx,
|
||||
},
|
||||
Punct {
|
||||
char: ',',
|
||||
spacing: Alone,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: leptos,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Joint,
|
||||
},
|
||||
Punct {
|
||||
char: ':',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: component_props_builder,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '&',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: SimpleCounter,
|
||||
span: bytes(11..24),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: initial_value,
|
||||
span: bytes(37..50),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 0,
|
||||
span: bytes(51..52),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: step,
|
||||
span: bytes(65..69),
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Punct {
|
||||
char: '#',
|
||||
spacing: Alone,
|
||||
},
|
||||
Group {
|
||||
delimiter: Bracket,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: allow,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [
|
||||
Ident {
|
||||
sym: unused_braces,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Literal {
|
||||
lit: 1,
|
||||
span: bytes(70..71),
|
||||
},
|
||||
],
|
||||
},
|
||||
Punct {
|
||||
char: '.',
|
||||
spacing: Alone,
|
||||
},
|
||||
Ident {
|
||||
sym: build,
|
||||
},
|
||||
Group {
|
||||
delimiter: Parenthesis,
|
||||
stream: TokenStream [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
---
|
||||
source: leptos_macro/src/view/tests.rs
|
||||
expression: pretty(result)
|
||||
---
|
||||
fn view() {
|
||||
{
|
||||
let _ = ::leptos::leptos_dom::html::div;
|
||||
let _ = ::leptos::leptos_dom::html::div;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::span;
|
||||
let _ = ::leptos::leptos_dom::html::span;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
let _ = ::leptos::leptos_dom::html::button;
|
||||
leptos::leptos_dom::helpers::ssr_event_listener(
|
||||
::leptos::ev::click,
|
||||
move |_| set_value(0),
|
||||
);
|
||||
leptos::leptos_dom::helpers::ssr_event_listener(
|
||||
::leptos::ev::click,
|
||||
move |_| set_value.update(|value| *value -= step),
|
||||
);
|
||||
leptos::leptos_dom::helpers::ssr_event_listener(
|
||||
::leptos::ev::click,
|
||||
move |_| set_value.update(|value| *value += step),
|
||||
);
|
||||
::leptos::HtmlElement::from_chunks(
|
||||
cx,
|
||||
::leptos::leptos_dom::html::Div::default(),
|
||||
[
|
||||
leptos::leptos_dom::html::StringOrView::String(
|
||||
format!(
|
||||
"<div id=\"_{}\"><button id=\"_{}\">Clear</button><button id=\"_{}\">-1</button><span id=\"_{}\">Value: ",
|
||||
::leptos::leptos_dom::HydrationCtx::peek(),
|
||||
::leptos::leptos_dom::HydrationCtx::id(),
|
||||
::leptos::leptos_dom::HydrationCtx::id(),
|
||||
::leptos::leptos_dom::HydrationCtx::id()
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
#[allow(unused_braces)]
|
||||
{
|
||||
let view = { { value } }.into_view(cx);
|
||||
leptos::leptos_dom::html::StringOrView::View(
|
||||
std::rc::Rc::new(move || view.clone()),
|
||||
)
|
||||
},
|
||||
leptos::leptos_dom::html::StringOrView::String(
|
||||
format!(
|
||||
"!</span><button id=\"_{}\">+1</button></div>",
|
||||
::leptos::leptos_dom::HydrationCtx::id()
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
119
leptos_macro/src/view/tests.rs
Normal file
119
leptos_macro/src/view/tests.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use std::str::FromStr;
|
||||
use syn::parse_quote;
|
||||
|
||||
fn pretty(input: TokenStream) -> String {
|
||||
let type_item: syn::Item = parse_quote! {
|
||||
fn view(){
|
||||
#input
|
||||
}
|
||||
};
|
||||
|
||||
let file = syn::File {
|
||||
shebang: None,
|
||||
attrs: vec![],
|
||||
items: vec![type_item],
|
||||
};
|
||||
|
||||
prettyplease::unparse(&file)
|
||||
}
|
||||
|
||||
macro_rules! assert_snapshot
|
||||
{
|
||||
(@assert text $result:ident) => {
|
||||
insta::assert_snapshot!(pretty($result))
|
||||
};
|
||||
(@assert full $result:ident) => {
|
||||
insta::assert_debug_snapshot!($result)
|
||||
};
|
||||
(client_template($assert:ident) => $input: expr) => {
|
||||
{
|
||||
let tokens = TokenStream::from_str($input).unwrap();
|
||||
let cx = Ident::new("cx", Span::call_site());
|
||||
let nodes = rstml::parse2(tokens).unwrap();
|
||||
let result = crate::view::client_template::render_template(&cx, &nodes);
|
||||
|
||||
assert_snapshot!(@assert $assert result)
|
||||
}
|
||||
};
|
||||
(client_builder($assert:ident) => $input: expr) => {
|
||||
{
|
||||
let tokens = TokenStream::from_str($input).unwrap();
|
||||
let cx = Ident::new("cx", Span::call_site());
|
||||
let nodes = rstml::parse2(tokens).unwrap();
|
||||
let mode = crate::view::Mode::Client;
|
||||
let global_class = None;
|
||||
let call_site = None;
|
||||
let result = crate::view::render_view(&cx, &nodes, mode, global_class, call_site);
|
||||
|
||||
assert_snapshot!(@assert $assert result)
|
||||
}
|
||||
};
|
||||
(server_template($assert:ident) => $input: expr) => {
|
||||
{
|
||||
let tokens = TokenStream::from_str($input).unwrap();
|
||||
let cx = Ident::new("cx", Span::call_site());
|
||||
let nodes = rstml::parse2(tokens).unwrap();
|
||||
let mode = crate::view::Mode::Ssr;
|
||||
let global_class = None;
|
||||
let call_site = None;
|
||||
let result = crate::view::render_view(&cx, &nodes, mode, global_class, call_site);
|
||||
|
||||
assert_snapshot!(@assert $assert result)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
macro_rules! for_all_modes {
|
||||
(@ $module: ident, $type: ident => $(
|
||||
$test_name:ident => $raw_str:expr
|
||||
),*
|
||||
) => {
|
||||
mod $module {
|
||||
use super::*;
|
||||
$(
|
||||
#[test]
|
||||
fn $test_name() {
|
||||
assert_snapshot!($type(text) => $raw_str)
|
||||
}
|
||||
)*
|
||||
mod full_span {
|
||||
use super::*;
|
||||
$(
|
||||
#[test]
|
||||
fn $test_name() {
|
||||
assert_snapshot!($type(full) => $raw_str)
|
||||
}
|
||||
)*
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
( $(
|
||||
$tts:tt
|
||||
)*
|
||||
) => {
|
||||
for_all_modes!{@ csr, client_builder => $($tts)*}
|
||||
for_all_modes!{@ client_template, client_template => $($tts)*}
|
||||
for_all_modes!{@ ssr, server_template => $($tts)*}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
for_all_modes! {
|
||||
test_simple_counter => r#"
|
||||
<div>
|
||||
<button on:click=move |_| set_value(0)>"Clear"</button>
|
||||
<button on:click=move |_| set_value.update(|value| *value -= step)>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button on:click=move |_| set_value.update(|value| *value += step)>"+1"</button>
|
||||
</div>
|
||||
"#,
|
||||
test_counter_component => r#"
|
||||
<SimpleCounter
|
||||
initial_value=0
|
||||
step=1
|
||||
/>
|
||||
"#
|
||||
}
|
||||
@@ -17,7 +17,6 @@ miniserde = { version = "0.1", optional = true }
|
||||
rkyv = { version = "0.7.39", features = [
|
||||
"validation",
|
||||
"uuid",
|
||||
"copy",
|
||||
"strict",
|
||||
], optional = true }
|
||||
bytecheck = { version = "0.7", features = [
|
||||
@@ -68,7 +67,7 @@ hydrate = [
|
||||
"dep:web-sys",
|
||||
]
|
||||
ssr = ["dep:tokio"]
|
||||
nightly = []
|
||||
nightly = ["rkyv?/copy"]
|
||||
serde = []
|
||||
serde-lite = ["dep:serde-lite"]
|
||||
miniserde = ["dep:miniserde"]
|
||||
|
||||
@@ -4,7 +4,7 @@ version = { workspace = true }
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gbj/leptos"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "RPC for the Leptos web framework."
|
||||
readme = "../README.md"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.4.8"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gbj/leptos"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "Tools to set HTML metadata in the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -51,7 +51,7 @@ where
|
||||
let key = key.into();
|
||||
let query_map = use_query_map(cx);
|
||||
let navigate = use_navigate(cx);
|
||||
let route = use_route(cx);
|
||||
let location = use_location(cx);
|
||||
|
||||
let get = create_memo(cx, {
|
||||
let key = key.clone();
|
||||
@@ -72,7 +72,7 @@ where
|
||||
}
|
||||
}
|
||||
let qs = new_query_map.to_query_string();
|
||||
let path = route.path();
|
||||
let path = location.pathname.get();
|
||||
let new_url = format!("{path}{qs}");
|
||||
let _ = navigate(&new_url, NavigateOptions::default());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user