feat: add leptos
All checks were successful
continuous-integration/drone/push Build is passing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-10-22 12:09:57 +02:00
parent 39d15b5d7f
commit 11323c0752
Signed by: kjuulh
GPG Key ID: 9AA7BC13CE474394
31 changed files with 4497 additions and 6 deletions

20
Cargo.lock generated
View File

@ -338,6 +338,16 @@ dependencies = [
"eyre",
]
[[package]]
name = "dagger-leptos"
version = "0.2.0"
dependencies = [
"async-trait",
"dagger-sdk",
"eyre",
"tokio",
]
[[package]]
name = "dagger-rust"
version = "0.2.0"
@ -932,6 +942,16 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "leptos-build"
version = "0.1.0"
dependencies = [
"dagger-rust",
"dagger-sdk",
"eyre",
"tokio",
]
[[package]]
name = "libc"
version = "0.2.149"

View File

@ -0,0 +1,16 @@
[package]
name = "dagger-leptos"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
repository.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dagger-sdk.workspace = true
eyre.workspace = true
async-trait.workspace = true
tokio.workspace = true

View File

@ -0,0 +1 @@

View File

@ -1,4 +1,4 @@
use std::{path::PathBuf, sync::Arc};
use std::path::PathBuf;
use crate::source::RustSource;
@ -251,7 +251,7 @@ pub enum RustVersion {
impl AsRef<RustVersion> for RustVersion {
fn as_ref(&self) -> &RustVersion {
&self
self
}
}
@ -287,7 +287,7 @@ impl BuildTarget {
}
}
fn into_platform(&self) -> dagger_sdk::Platform {
pub fn into_platform(&self) -> dagger_sdk::Platform {
let platform = match self {
BuildTarget::LinuxAmd64 => "linux/amd64",
BuildTarget::LinuxArm64 => "linux/arm64",
@ -303,7 +303,7 @@ impl BuildTarget {
impl AsRef<BuildTarget> for BuildTarget {
fn as_ref(&self) -> &BuildTarget {
&self
self
}
}
@ -329,7 +329,7 @@ pub enum BuildProfile {
impl AsRef<BuildProfile> for BuildProfile {
fn as_ref(&self) -> &BuildProfile {
&self
self
}
}

View File

@ -2,3 +2,259 @@ pub mod build;
pub mod publish;
pub mod source;
pub mod test;
pub mod leptos {
use std::path::PathBuf;
use crate::{
build::{BuildProfile, BuildTarget, RustVersion, SlimImage},
source::RustSource,
};
pub struct LeptosBuild {
client: dagger_sdk::Query,
registry: Option<String>,
}
impl LeptosBuild {
pub fn new(client: dagger_sdk::Query) -> Self {
Self {
client,
registry: None,
}
}
pub async fn build(
&self,
source_path: Option<impl Into<PathBuf>>,
rust_version: impl AsRef<RustVersion>,
profile: impl AsRef<BuildProfile>,
crates: &[&str],
extra_deps: &[&str],
) -> eyre::Result<dagger_sdk::Container> {
let source_path = source_path.map(|s| s.into()).unwrap_or(PathBuf::from("."));
let rust_version = rust_version.as_ref();
let profile = profile.as_ref();
let rust_source = RustSource::new(self.client.clone());
let (src, dep_src) = rust_source
.get_rust_src(Some(&source_path), crates.to_vec())
.await?;
let mut deps = vec!["apt", "install", "-y"];
deps.extend(extra_deps);
let rust_build_image = self
.client
.container()
.from(rust_version.to_string())
.with_exec(vec!["rustup", "target", "add", "wasm32-unknown-unknown"])
.with_exec(vec!["apt", "update"])
.with_exec(deps)
.with_exec(vec!["wget", "https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz"])
.with_exec("tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz".split_whitespace().collect())
.with_exec("mv cargo-binstall /usr/local/cargo/bin".split_whitespace().collect())
.with_exec(vec!["cargo", "binstall", "cargo-leptos", "-y"]);
let target_cache = self
.client
.cache_volume(format!("rust_leptos_{}", profile.to_string()));
let mut build_options = vec!["cargo", "leptos", "build", "--release", "-vv"];
let rust_prebuild = rust_build_image
.with_workdir("/mnt/src")
.with_directory("/mnt/src", dep_src)
.with_exec(build_options)
.with_mounted_cache("/mnt/src/target/", target_cache);
let incremental_dir = rust_source
.get_rust_target_src(&source_path, rust_prebuild.clone(), crates.to_vec())
.await?;
let rust_with_src = rust_build_image
.with_workdir("/mnt/src")
.with_directory(
"/usr/local/cargo",
rust_prebuild.directory("/usr/local/cargo"),
)
.with_directory("/mnt/src/target", incremental_dir)
.with_directory("/mnt/src/", src);
Ok(rust_with_src)
}
pub async fn build_release(
&self,
source_path: Option<impl Into<PathBuf>>,
rust_version: impl AsRef<RustVersion>,
crates: &[&str],
extra_deps: &[&str],
images: impl IntoIterator<Item = SlimImage>,
bin_name: &str,
) -> eyre::Result<Vec<dagger_sdk::Container>> {
let images = images.into_iter().collect::<Vec<_>>();
let source_path = source_path.map(|s| s.into());
let mut containers = Vec::new();
for container_image in images {
let container = match &container_image {
SlimImage::Debian { image, deps, .. } => {
let target = BuildTarget::from_target(&container_image);
let build_container = self
.build(
source_path.clone(),
&rust_version,
BuildProfile::Release,
crates,
extra_deps,
)
.await?;
let binary_build = build_container.with_exec(vec![
"cargo",
"leptos",
"build",
"--release",
"-vv",
]);
self.build_debian_image(
binary_build,
image,
BuildTarget::from_target(&container_image),
deps.iter()
.map(|d| d.as_str())
.collect::<Vec<&str>>()
.as_slice(),
bin_name,
)
.await?
}
SlimImage::Alpine { image, deps, .. } => {
let target = BuildTarget::from_target(&container_image);
let build_container = self
.build(
source_path.clone(),
&rust_version,
BuildProfile::Release,
crates,
extra_deps,
)
.await?;
let bin = build_container
.with_exec(vec![
"cargo",
"build",
"--target",
&target.to_string(),
"--release",
"-p",
bin_name,
])
.file(format!(
"target/{}/release/{}",
target.to_string(),
bin_name
));
self.build_alpine_image(
bin,
image,
BuildTarget::from_target(&container_image),
deps.iter()
.map(|d| d.as_str())
.collect::<Vec<&str>>()
.as_slice(),
bin_name,
)
.await?
}
};
containers.push(container);
}
Ok(containers)
}
async fn build_debian_image(
&self,
builder_image: dagger_sdk::Container,
image: &str,
target: BuildTarget,
production_deps: &[&str],
bin_name: &str,
) -> eyre::Result<dagger_sdk::Container> {
let base_debian = self
.client
.container_opts(dagger_sdk::QueryContainerOpts {
id: None,
platform: Some(target.into_platform()),
})
.from(image);
let mut packages = vec!["apt", "install", "-y"];
packages.extend_from_slice(production_deps);
let base_debian = base_debian
.with_exec(vec!["apt", "update"])
.with_exec(packages);
let final_image = base_debian
.with_workdir("/mnt/app")
.with_file(
format!("/mnt/app/{bin_name}"),
builder_image.file(format!("/mnt/src/target/release/{bin_name}")),
)
.with_directory(
"/mnt/app/target/site",
builder_image.directory(format!("/mnt/src/target/site")),
)
.with_file(
"/mnt/app/Cargo.toml",
builder_image.file(format!("/mnt/src/crates/{bin_name}/Cargo.toml")),
)
.with_env_variable("RUST_LOG", "debug")
.with_env_variable("APP_ENVIRONMENT", "production")
.with_env_variable("LEPTOS_OUTPUT_NAME", bin_name)
.with_env_variable("LEPTOS_SITE_ADDR", "0.0.0.0:8080")
.with_env_variable("LEPTOS_SITE_ROOT", "site")
.with_env_variable("LEPTOS_SITE_PKG_DIR", "pkg")
.with_exposed_port(8080)
.with_entrypoint(vec![format!("/mnt/app/{bin_name}")]);
final_image.sync().await?;
Ok(final_image)
}
async fn build_alpine_image(
&self,
bin: dagger_sdk::File,
image: &str,
target: BuildTarget,
production_deps: &[&str],
bin_name: &str,
) -> eyre::Result<dagger_sdk::Container> {
let base_debian = self
.client
.container_opts(dagger_sdk::QueryContainerOpts {
id: None,
platform: Some(target.into_platform()),
})
.from(image);
let mut packages = vec!["apk", "add"];
packages.extend_from_slice(production_deps);
let base_debian = base_debian.with_exec(packages);
let final_image = base_debian.with_file(format!("/usr/local/bin/{}", bin_name), bin);
Ok(final_image)
}
}
}

View File

@ -90,10 +90,14 @@ impl RustSource {
) -> eyre::Result<dagger_sdk::Directory> {
let source = source.map(|s| s.into()).unwrap_or(PathBuf::from("."));
let mut excludes = self.exclude.clone();
excludes.push("**/src".to_string());
let directory = self.client.host().directory_opts(
source.display().to_string(),
dagger_sdk::HostDirectoryOptsBuilder::default()
.include(vec!["**/Cargo.toml", "**/Cargo.lock"])
//.include(vec!["**/Cargo.toml", "**/Cargo.lock"])
.exclude(excludes.iter().map(|s| s.as_str()).collect::<Vec<_>>())
.build()?,
);

View File

@ -0,0 +1,13 @@
[package]
name = "leptos-build"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dagger-rust.workspace = true
eyre.workspace = true
dagger-sdk.workspace = true
tokio.workspace = true

View File

@ -0,0 +1,97 @@
[package]
name = "hackernews_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
leptos = { version = "0.5.1", features = ["nightly"] }
leptos_axum = { version = "0.5.1", optional = true }
leptos_meta = { version = "0.5.1", features = ["nightly"] }
leptos_router = { version = "0.5.1", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
[features]
default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[package.metadata.cargo-all-features]
denylist = ["axum", "tower", "tower-http", "tokio", "http", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:8080"
# 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"
# 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
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

Binary file not shown.

View File

@ -0,0 +1,62 @@
use dagger_rust::build::{BuildProfile, RustVersion, SlimImage};
#[tokio::main]
pub async fn main() -> eyre::Result<()> {
let client = dagger_sdk::connect().await?;
let rust_build = dagger_rust::leptos::LeptosBuild::new(client.clone());
let containers = rust_build
.build_release(
Some("testdata"),
RustVersion::Nightly,
&["crates/*"],
&[
"openssl",
"libssl-dev",
"pkg-config",
"musl-tools",
"ca-certificates",
],
vec![SlimImage::Debian {
image: "debian:bullseye".into(),
deps: vec![
"openssl".into(),
"libssl-dev".into(),
"pkg-config".into(),
"musl-tools".into(),
"ca-certificates".into(),
],
architecture: dagger_rust::build::BuildArchitecture::Amd64,
}],
"hackernews_axum",
)
.await?;
let container = containers.first().unwrap();
container.directory("/mnt/app").export("output").await?;
let tunnel = client.host().tunnel(
container
.with_env_variable("LEPTOS_SITE_ADDR", "0.0.0.0:8080")
.with_exec(vec!["/mnt/app/hackernews_axum"])
.as_service(),
);
tunnel.start().await?;
let endpoint = tunnel
.endpoint_opts(
dagger_sdk::ServiceEndpointOptsBuilder::default()
.scheme("http")
.build()?,
)
.await?;
println!("running on: {endpoint}, press enter to stop");
std::io::stdin().read_line(&mut String::new()).unwrap();
Ok(())
}

2809
examples/leptos-build/testdata/Cargo.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[profile.release]
codegen-units = 1
lto = true
[[workspace.metadata.leptos]]
name = "hackernews_axum"
bin-package = "hackernews_axum"
lib-package = "hackernews_axum"
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./crates/hackernews_axum/style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "./crates/hackernews_axum/public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:8080"
# 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"
# 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
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

@ -0,0 +1 @@
/target

View File

@ -0,0 +1,97 @@
[package]
name = "hackernews_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
leptos = { version = "0.5.1", features = ["nightly"] }
leptos_axum = { version = "0.5.1", optional = true }
leptos_meta = { version = "0.5.1", features = ["nightly"] }
leptos_router = { version = "0.5.1", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
[features]
default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[package.metadata.cargo-all-features]
denylist = ["axum", "tower", "tower-http", "tokio", "http", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:8080"
# 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"
# 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
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Greg Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,8 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]
CLIENT_PROCESS_NAME = "hackernews_axum"

View File

@ -0,0 +1,7 @@
# Leptos Hacker News Example with Axum
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to create both a client-side rendered app, and a server side rendered app with hydration, in a single repository. This repo differs from the main Hacker News example by using Axum as it's server.
## Getting Started
See the [Examples README](../README.md) for setup and run instructions.

View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="css" href="/style.css"/>
</head>
<body></body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,90 @@
use leptos::Serializable;
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
format!("https://node-hnapi.herokuapp.com/{path}")
}
pub fn user(path: &str) -> String {
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
}
#[cfg(not(feature = "ssr"))]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serializable,
{
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if e.g., we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.await
.ok()?;
T::de(&json).ok()
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serializable,
{
let json = reqwest::get(path)
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.await
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct Story {
pub id: usize,
pub title: String,
pub points: Option<i32>,
pub user: Option<String>,
pub time: usize,
pub time_ago: String,
#[serde(alias = "type")]
pub story_type: String,
pub url: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub comments: Option<Vec<Comment>>,
pub comments_count: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct Comment {
pub id: usize,
pub level: usize,
pub user: Option<String>,
pub time: usize,
pub time_ago: String,
pub content: Option<String>,
pub comments: Vec<Comment>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct User {
pub created: usize,
pub id: String,
pub karma: i32,
pub about: Option<String>,
}

View File

@ -0,0 +1,28 @@
use leptos::{view, Errors, For, IntoView, RwSignal, View};
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
pub fn error_template(errors: Option<RwSignal<Errors>>) -> View {
let Some(errors) = errors else {
panic!("No Errors found and we expected errors!");
};
view! {
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=errors
// a unique key for each item as a reference
key=|(key, _)| key.clone()
// renders each item to a view
children=move | (_, error)| {
let error_string = error.to_string();
view! {
<p>"Error: " {error_string}</p>
}
}
/>
}
.into_view()
}

View File

@ -0,0 +1,44 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::{LeptosOptions};
use crate::error_template::error_template;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else{
let handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template( None));
handler(req).await.into_response()
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
}
}
}

View File

@ -0,0 +1,63 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
http::{Request, Response, StatusCode, Uri},
};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
let res = get_static_file(uri.clone(), "/pkg").await?;
if res.status() == StatusCode::NOT_FOUND {
// try with `.html`
// TODO: handle if the Uri has query parameters
match format!("{}.html", uri).parse() {
Ok(uri_html) => get_static_file(uri_html, "/pkg").await,
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())),
}
} else {
Ok(res)
}
}
pub async fn get_static_file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
let res = get_static_file(uri.clone(), "/static").await?;
if res.status() == StatusCode::NOT_FOUND {
Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string()))
} else {
Ok(res)
}
}
async fn get_static_file(uri: Uri, base: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
let req = Request::builder().uri(&uri).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// When run normally, the root should be the crate root
if base == "/static" {
match ServeDir::new("./static").oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
))
}
} else if base == "/pkg" {
match ServeDir::new("./pkg").oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
} else{
Err((StatusCode::NOT_FOUND, "Not Found".to_string()))
}
}
}
}

View File

@ -0,0 +1,49 @@
use cfg_if::cfg_if;
use leptos::{component, view, IntoView};
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
pub mod fallback;
pub mod handlers;
mod routes;
use routes::{nav::*, stories::*, story::*, users::*};
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/hackernews_axum.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
</main>
</Router>
</>
}
}
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(move || {
view! { <App/> }
});
}
}
}

View File

@ -0,0 +1,54 @@
use cfg_if::cfg_if;
use leptos::{logging::log, *};
// boilerplate to run in different modes
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
Router,
routing::get,
};
use leptos_axum::{generate_route_list, LeptosRoutes};
use hackernews_axum::fallback::file_and_error_handler;
#[tokio::main]
async fn main() {
use hackernews_axum::*;
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
// build our application with a route
let app = Router::new()
.route("/favicon.ico", get(file_and_error_handler))
.leptos_routes(&leptos_options, routes, || view! { <App/> } )
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
}
// client-only stuff for Trunk
else {
use hackernews_axum::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <App/> }
});
}
}
}

View File

@ -0,0 +1,4 @@
pub mod nav;
pub mod stories;
pub mod story;
pub mod users;

View File

@ -0,0 +1,30 @@
use leptos::{component, view, IntoView};
use leptos_router::*;
#[component]
pub fn Nav() -> impl IntoView {
view! {
<header class="header">
<nav class="inner">
<A href="/">
<strong>"HN"</strong>
</A>
<A href="/new">
<strong>"New"</strong>
</A>
<A href="/show">
<strong>"Show"</strong>
</A>
<A href="/ask">
<strong>"Ask"</strong>
</A>
<A href="/job">
<strong>"Jobs"</strong>
</A>
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
"Built with Leptos"
</a>
</nav>
</header>
}
}

View File

@ -0,0 +1,156 @@
use crate::api;
use leptos::*;
use leptos_router::*;
fn category(from: &str) -> &'static str {
match from {
"new" => "newest",
"show" => "show",
"ask" => "ask",
"job" => "jobs",
_ => "news",
}
}
#[component]
pub fn Stories() -> impl IntoView {
let query = use_query_map();
let params = use_params_map();
let page = move || {
query
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
.unwrap_or(1)
};
let story_type = move || {
params
.with(|p| p.get("stories").cloned())
.unwrap_or_else(|| "top".to_string())
};
let stories = create_resource(
move || (page(), story_type()),
move |(page, story_type)| async move {
let path = format!("{}?page={}", category(&story_type), page);
api::fetch_api::<Vec<api::Story>>(&api::story(&path)).await
},
);
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending()
};
view! {
<div class="news-view">
<div class="news-list-nav">
<span>
{move || if page() > 1 {
view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
attr:aria_label="Previous Page"
>
"< prev"
</a>
}.into_any()
} else {
view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
}.into_any()
}}
</span>
<span>"page " {page}</span>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
</a>
</span>
</div>
<main class="news-list">
<div>
<Transition
fallback=move || view! { <p>"Loading..."</p> }
set_pending
>
{move || match stories.get() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
Some(view! {
<ul>
<For
each=move || stories.clone()
key=|story| story.id
let:story
>
<Story story/>
</For>
</ul>
}.into_any())
}
}}
</Transition>
</div>
</main>
</div>
}
}
#[component]
fn Story(story: api::Story) -> impl IntoView {
view! {
<li class="news-item">
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
}.into_view()
} else {
let title = story.title.clone();
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
<br />
<span class="meta">
{if story.story_type != "job" {
view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
{format!(" {} | ", story.time_ago)}
<A href=format!("/stories/{}", story.id)>
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"discuss".into()
}}
</A>
</span>
}.into_view()
} else {
let title = story.title.clone();
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
{(story.story_type != "link").then(|| view! {
" "
<span class="label">{story.story_type}</span>
})}
</li>
}
}

View File

@ -0,0 +1,125 @@
use crate::api;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn Story() -> impl IntoView {
let params = use_params_map();
let story = create_resource(
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
} else {
api::fetch_api::<api::Story>(&api::story(&format!("item/{id}")))
.await
}
},
);
let meta_description = move || {
story
.get()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
view! {
<Suspense fallback=|| view! { "Loading..." }>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
</div>
}})}
</Suspense>
}
}
#[component]
pub fn Comment(comment: api::Comment) -> impl IntoView {
let (open, set_open) = create_signal(true);
view! {
<li class="comment">
<div class="by">
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
{format!(" {}", comment.time_ago)}
</div>
<div class="text" inner_html=comment.content></div>
{(!comment.comments.is_empty()).then(|| {
view! {
<div>
<div class="toggle" class:open=open>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{
let comments_len = comment.comments.len();
move || if open() {
"[-]".into()
} else {
format!("[+] {}{} collapsed", comments_len, pluralize(comments_len))
}
}
</a>
</div>
{move || open().then({
let comments = comment.comments.clone();
move || view! {
<ul class="comment-children">
<For
each=move || comments.clone()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
}
})}
</div>
}
})}
</li>
}
}
fn pluralize(n: usize) -> &'static str {
if n == 1 {
" reply"
} else {
" replies"
}
}

View File

@ -0,0 +1,46 @@
use crate::api::{self, User};
use leptos::*;
use leptos_router::*;
#[component]
pub fn User() -> impl IntoView {
let params = use_params_map();
let user = create_resource(
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
} else {
api::fetch_api::<User>(&api::user(&id)).await
}
},
);
view! {
<div class="user-view">
<Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_any(),
Some(user) => view! {
<div>
<h1>"User: " {&user.id}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span> {user.created}
</li>
<li>
<span class="label">"Karma: "</span> {user.karma}
</li>
{user.about.as_ref().map(|about| view! { <li inner_html=about class="about"></li> })}
</ul>
<p class="links">
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
" | "
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
}.into_any()
})}
</Suspense>
</div>
}
}

View File

@ -0,0 +1,326 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 15px;
background-color: #f2f3f5;
margin: 0;
padding-top: 55px;
color: #34495e;
overflow-y: scroll
}
a {
color: #34495e;
text-decoration: none
}
.header {
background-color: #335d92;
position: fixed;
z-index: 999;
height: 55px;
top: 0;
left: 0;
right: 0
}
.header .inner {
max-width: 800px;
box-sizing: border-box;
margin: 0 auto;
padding: 15px 5px
}
.header a {
color: rgba(255, 255, 255, .8);
line-height: 24px;
transition: color .15s ease;
display: inline-block;
vertical-align: middle;
font-weight: 300;
letter-spacing: .075em;
margin-right: 1.8em
}
.header a:hover {
color: #fff
}
.header a.active {
color: #fff;
font-weight: 400
}
.header a:nth-child(6) {
margin-right: 0
}
.header .github {
color: #fff;
font-size: .9em;
margin: 0;
float: right
}
.logo {
width: 24px;
margin-right: 10px;
display: inline-block;
vertical-align: middle
}
.view {
max-width: 800px;
margin: 0 auto;
position: relative
}
.fade-enter-active,
.fade-exit-active {
transition: all .2s ease
}
.fade-enter,
.fade-exit-active {
opacity: 0
}
@media (max-width:860px) {
.header .inner {
padding: 15px 30px
}
}
@media (max-width:600px) {
.header .inner {
padding: 15px
}
.header a {
margin-right: 1em
}
.header .github {
display: none
}
}
.news-view {
padding-top: 45px
}
.news-list,
.news-list-nav {
background-color: #fff;
border-radius: 2px
}
.news-list-nav {
padding: 15px 30px;
position: fixed;
text-align: center;
top: 55px;
left: 0;
right: 0;
z-index: 998;
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
}
.news-list-nav .page-link {
margin: 0 1em
}
.news-list-nav .disabled {
color: #aaa
}
.news-list {
position: absolute;
margin: 30px 0;
width: 100%;
transition: all .5s cubic-bezier(.55, 0, .1, 1)
}
.news-list ul {
list-style-type: none;
padding: 0;
margin: 0
}
@media (max-width:600px) {
.news-list {
margin: 10px 0
}
}
.news-item {
background-color: #fff;
padding: 20px 30px 20px 80px;
border-bottom: 1px solid #eee;
position: relative;
line-height: 20px
}
.news-item .score {
color: #335d92;
font-size: 1.1em;
font-weight: 700;
position: absolute;
top: 50%;
left: 0;
width: 80px;
text-align: center;
margin-top: -10px
}
.news-item .host,
.news-item .meta {
font-size: .85em;
color: #626262
}
.news-item .host a,
.news-item .meta a {
color: #626262;
text-decoration: underline
}
.news-item .host a:hover,
.news-item .meta a:hover {
color: #335d92
}
.item-view-header {
background-color: #fff;
padding: 1.8em 2em 1em;
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
}
.item-view-header h1 {
display: inline;
font-size: 1.5em;
margin: 0;
margin-right: .5em
}
.item-view-header .host,
.item-view-header .meta,
.item-view-header .meta a {
color: #626262
}
.item-view-header .meta a {
text-decoration: underline
}
.item-view-comments {
background-color: #fff;
margin-top: 10px;
padding: 0 2em .5em
}
.item-view-comments-header {
margin: 0;
font-size: 1.1em;
padding: 1em 0;
position: relative
}
.item-view-comments-header .spinner {
display: inline-block;
margin: -15px 0
}
.comment-children {
list-style-type: none;
padding: 0;
margin: 0
}
@media (max-width:600px) {
.item-view-header h1 {
font-size: 1.25em
}
}
.comment-children .comment-children {
margin-left: 1.5em
}
.comment {
border-top: 1px solid #eee;
position: relative
}
.comment .by,
.comment .text,
.comment .toggle {
font-size: .9em;
margin: 1em 0
}
.comment .by {
color: #626262
}
.comment .by a {
color: #626262;
text-decoration: underline
}
.comment .text {
overflow-wrap: break-word
}
.comment .text a:hover {
color: #335d92
}
.comment .text pre {
white-space: pre-wrap
}
.comment .toggle {
background-color: #fffbf2;
padding: .3em .5em;
border-radius: 4px
}
.comment .toggle a {
color: #626262;
cursor: pointer
}
.comment .toggle.open {
padding: 0;
background-color: transparent;
margin-bottom: -.5em
}
.user-view {
background-color: #fff;
box-sizing: border-box;
padding: 2em 3em
}
.user-view h1 {
margin: 0;
font-size: 1.5em
}
.user-view .meta {
list-style-type: none;
padding: 0
}
.user-view .label {
display: inline-block;
min-width: 4em
}
.user-view .about {
margin: 1em 0
}
.user-view .links a {
text-decoration: underline
}