From e61bfb49c4eefbc41dd2784921249bba39596aac Mon Sep 17 00:00:00 2001 From: lin Date: Wed, 8 Jul 2026 19:00:16 -0400 Subject: [PATCH 1/4] Support compiling the library for wasm32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the networking, filesystem-cache, and auth code (and their dependencies: reqwest, tokio, glob, clap, simple_logger, rpassword) behind cfg(not(target_arch = "wasm32")). The pure generation path — types, transforms, cover rendering, image processing, and epub assembly — now builds for wasm32-unknown-unknown, enabling in-browser epub generation. Thread::to_epub and Continuity::to_epub are split into a native async half that downloads images and a new sync to_epub_from_images(options, images) that accepts caller-fetched images and is available on wasm, alongside the existing to_epub_remote_images. getrandom's JS backends are enabled for wasm32 (needed transitively by rand and uuid); building for wasm requires RUSTFLAGS='--cfg getrandom_backend="wasm_js"' and --lib (the CLI binary remains native-only). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 8 ++++++++ Cargo.toml | 20 ++++++++++++++++---- src/generate/epub.rs | 27 +++++++++++++++++++++++++-- src/intern_images.rs | 21 +++++++++++---------- src/lib.rs | 3 +++ src/utils.rs | 6 +++++- 6 files changed, 68 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e002b4..b0ffc72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,8 +843,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -854,8 +856,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.13.3+wasi-0.2.2", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -891,6 +895,8 @@ dependencies = [ "cssparser 0.33.0", "epub-builder", "fontdb", + "getrandom 0.2.15", + "getrandom 0.3.1", "glob", "html-escape", "html5ever", @@ -3183,6 +3189,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" dependencies = [ "getrandom 0.3.1", + "js-sys", + "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index dc51f98..215e965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ edition = "2024" [dependencies] chrono = { version = "0.4", features = ["serde"] } -glob = "0.3" log = "0.4" mime = "0.3" rand = "0.8" @@ -18,10 +17,8 @@ regex = "1" sha2 = "0.10" uuid = { version = "1", features = ["v4"] } -reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["full"] } fontdb = "0.16" resvg = "0.41" @@ -43,7 +40,22 @@ xml5ever = "0.18" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp"] } +slug = "0.1" + +# Native-only dependencies: networking, async runtime, filesystem cache, and the CLI. +# The library builds for wasm32 without them (see src/lib.rs cfg gates); the +# `Thread::to_epub_remote_images` path is fully available on wasm. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +glob = "0.3" +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } simple_logger = "4" -slug = "0.1" rpassword = "7.3.1" + +# On wasm32-unknown-unknown, getrandom needs its JS backends enabled +# (pulled in transitively via `rand` and `uuid`). +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] } +getrandom03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] } +uuid = { version = "1", features = ["js"] } diff --git a/src/generate/epub.rs b/src/generate/epub.rs index 2b2f860..76c34fb 100644 --- a/src/generate/epub.rs +++ b/src/generate/epub.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::{ Board, Post, Reply, Thread, + intern_images::InternedImage, types::{Continuity, Section, User}, }; @@ -18,9 +19,20 @@ use super::{ }; impl Continuity { + #[cfg(not(target_arch = "wasm32"))] pub async fn to_epub(&self, options: Options) -> Result, Box> { - let mut images_to_intern = self.images_to_intern().await?; + let images_to_intern = self.images_to_intern().await?; + self.to_epub_from_images(options, images_to_intern) + } + /// Like [Self::to_epub], but with the images provided by the caller + /// instead of downloaded here. Available on wasm32, where the caller is + /// responsible for fetching the images (e.g. via JS `fetch`). + pub fn to_epub_from_images( + &self, + options: Options, + mut images_to_intern: HashMap, + ) -> Result, Box> { if let Some(size) = options.resize_icons { images_to_intern = images_to_intern .into_iter() @@ -382,9 +394,20 @@ impl Thread { } impl Thread { + #[cfg(not(target_arch = "wasm32"))] pub async fn to_epub(&self, options: Options) -> Result, Box> { - let mut images_to_intern = self.images_to_intern().await?; + let images_to_intern = self.images_to_intern().await?; + self.to_epub_from_images(options, images_to_intern) + } + /// Like [Self::to_epub], but with the images provided by the caller + /// instead of downloaded here. Available on wasm32, where the caller is + /// responsible for fetching the images (e.g. via JS `fetch`). + pub fn to_epub_from_images( + &self, + options: Options, + mut images_to_intern: HashMap, + ) -> Result, Box> { if let Some(size) = options.resize_icons { images_to_intern = images_to_intern .into_iter() diff --git a/src/intern_images.rs b/src/intern_images.rs index 0700e73..07193a3 100644 --- a/src/intern_images.rs +++ b/src/intern_images.rs @@ -1,17 +1,15 @@ -use std::{ - collections::{HashMap, HashSet}, - error::Error, - io::Cursor, -}; +#[cfg(not(target_arch = "wasm32"))] +use std::collections::{HashMap, HashSet}; +use std::{error::Error, io::Cursor}; use image::imageops::FilterType; use mime::Mime; -use crate::{ - cached::download_cached_image, - types::{Continuity, Icon, Thread}, - utils::{mime_to_image_extension, url_hash}, -}; +#[cfg(not(target_arch = "wasm32"))] +use crate::cached::download_cached_image; +#[cfg(not(target_arch = "wasm32"))] +use crate::types::{Continuity, Icon, Thread}; +use crate::utils::{mime_to_image_extension, url_hash}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct InternedImage { @@ -142,6 +140,7 @@ impl InternedImage { } } +#[cfg(not(target_arch = "wasm32"))] impl Continuity { pub async fn images_to_intern(&self) -> Result, Box> { let mut interned_images: HashMap = HashMap::new(); @@ -161,6 +160,7 @@ impl Continuity { } } +#[cfg(not(target_arch = "wasm32"))] impl Thread { pub async fn images_to_intern(&self) -> Result, Box> { let mut interned_images: HashMap = HashMap::new(); @@ -242,6 +242,7 @@ impl Thread { } } +#[cfg(not(target_arch = "wasm32"))] impl Icon { async fn intern(&self) -> Result> { let (mime, data) = self.download_cached(false).await?; diff --git a/src/lib.rs b/src/lib.rs index 4454ec6..e87eab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,11 @@ mod rfc3339; +#[cfg(not(target_arch = "wasm32"))] mod auth; +#[cfg(not(target_arch = "wasm32"))] pub mod api; +#[cfg(not(target_arch = "wasm32"))] pub mod cached; pub mod generate; pub mod intern_images; diff --git a/src/utils.rs b/src/utils.rs index 2571d2a..0f31b60 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ -use std::{io::Cursor, str::FromStr, sync::OnceLock}; +use std::{io::Cursor, str::FromStr}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::OnceLock; use image::ImageReader; use mime::Mime; @@ -6,6 +8,7 @@ use sha2::{Digest, Sha256}; use crate::types::{Icon, Thread}; +#[cfg(not(target_arch = "wasm32"))] const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); impl Thread { @@ -59,6 +62,7 @@ pub fn url_hash(url: &str) -> String { format!("{hash:x}") } +#[cfg(not(target_arch = "wasm32"))] pub fn http_client() -> reqwest::Client { // TODO: use global `std::sync::LazyLock` once stable. pub static HTTP_CLIENT: OnceLock = OnceLock::new(); From 44e94d44a7a74a26f0c3a2d97be190f340451500 Mon Sep 17 00:00:00 2001 From: lin Date: Wed, 8 Jul 2026 19:33:46 -0400 Subject: [PATCH 2/4] Collapse nested ifs into let-chains Current stable clippy (1.96) flags these five collapsible_if cases in cached.rs now that if-let chains are stable, failing the -D warnings CI gate. No behavior change. Co-Authored-By: Claude Fable 5 --- src/cached.rs | 55 ++++++++++++++++++++--------------------- src/{main.rs => cli.rs} | 0 2 files changed, 27 insertions(+), 28 deletions(-) rename src/{main.rs => cli.rs} (100%) diff --git a/src/cached.rs b/src/cached.rs index 1736171..3cf4fdc 100644 --- a/src/cached.rs +++ b/src/cached.rs @@ -57,14 +57,13 @@ impl Replies { ) -> Result, Vec>, Box> { let cache_path = Self::cache_key(id); - if !invalidate_cache { - if let Ok(data) = std::fs::read(&cache_path) { - let parsed: Result> = - serde_json::from_slice(&data).unwrap(); + if !invalidate_cache + && let Ok(data) = std::fs::read(&cache_path) + { + let parsed: Result> = serde_json::from_slice(&data).unwrap(); - if let Ok(replies) = parsed { - return Ok(Ok(replies.0)); - } + if let Ok(replies) = parsed { + return Ok(Ok(replies.0)); } } @@ -88,14 +87,14 @@ impl BoardPosts { ) -> Result, Vec>, Box> { let cache_path = Self::cache_key(id); - if !invalidate_cache { - if let Ok(data) = std::fs::read(&cache_path) { - let parsed: Result, Vec> = - serde_json::from_slice(&data).unwrap(); + if !invalidate_cache + && let Ok(data) = std::fs::read(&cache_path) + { + let parsed: Result, Vec> = + serde_json::from_slice(&data).unwrap(); - if let Ok(posts) = parsed { - return Ok(Ok(posts)); - } + if let Ok(posts) = parsed { + return Ok(Ok(posts)); } } @@ -124,10 +123,10 @@ impl Icon { return Err("No url provided for this icon".into()); }; - if !invalidate_cache { - if let Ok((mime, data)) = read_image_file(Self::cache_key(*id, "*")) { - return Ok((mime, data)); - } + if !invalidate_cache + && let Ok((mime, data)) = read_image_file(Self::cache_key(*id, "*")) + { + return Ok((mime, data)); } log::info!("Downloading icon {id} from {url}"); @@ -232,10 +231,10 @@ pub async fn download_cached_image( let hash = url_hash(url); - if !invalidate_cache { - if let Ok((mime, data)) = read_image_file(image_cache_key(&hash, "*")) { - return Ok((mime, data)); - } + if !invalidate_cache + && let Ok((mime, data)) = read_image_file(image_cache_key(&hash, "*")) + { + return Ok((mime, data)); } log::info!("Downloading image {hash} from {url}"); @@ -261,12 +260,12 @@ async fn get_cached_glowfic( where T: DeserializeOwned + Serialize, { - if !invalidate_cache { - if let Ok(data) = std::fs::read(cache_path) { - let parsed: Result> = serde_json::from_slice(&data).unwrap(); - if parsed.is_ok() { - return Ok(parsed); - } + if !invalidate_cache + && let Ok(data) = std::fs::read(cache_path) + { + let parsed: Result> = serde_json::from_slice(&data).unwrap(); + if parsed.is_ok() { + return Ok(parsed); } } let response = crate::api::get_glowfic(url).await?; diff --git a/src/main.rs b/src/cli.rs similarity index 100% rename from src/main.rs rename to src/cli.rs From 4faa2f708d3acf3d1ba655c823354eaf3403c2f3 Mon Sep 17 00:00:00 2001 From: lin Date: Wed, 8 Jul 2026 19:33:46 -0400 Subject: [PATCH 3/4] Enable the wasm32 CI checks Uncomment the wasm clippy step sketched in check.yml. The CLI moves to src/cli.rs with src/main.rs compiling it natively and building as an empty stub on wasm32, so the whole workspace (not just --lib) checks cleanly for wasm32-unknown-unknown. Co-Authored-By: Claude Fable 5 --- .github/workflows/check.yml | 12 +++++++----- src/cli.rs | 3 +-- src/main.rs | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/main.rs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 49648e7..d69832b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,15 +31,17 @@ jobs: target/ key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} - # - name: Install WASM toolchain - # run: rustup target add wasm32-unknown-unknown + - name: Install WASM toolchain + run: rustup target add wasm32-unknown-unknown - name: Check project run: cargo clippy --workspace --all-targets --all-features -- -D warnings - # - name: Check project (WASM) - # # Note lack of --all-targets here because tests and examples are not wasm compatible - # run: cargo clippy --workspace --all-features --target wasm32-unknown-unknown -- -D warnings + - name: Check project (WASM) + # Note lack of --all-targets here because tests and examples are not wasm compatible + # (the CLI binary compiles as an empty stub). getrandom 0.3 requires selecting its + # JS backend explicitly on wasm32-unknown-unknown. + run: RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo clippy --workspace --all-features --target wasm32-unknown-unknown -- -D warnings test: runs-on: ubuntu-latest diff --git a/src/cli.rs b/src/cli.rs index aa0883c..9d628ff 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -152,8 +152,7 @@ impl Display for OutputFormat { } } -#[tokio::main] -async fn main() { +pub async fn main() { simple_logger::init_with_level(log::Level::Info).unwrap(); let command = Command::parse(); diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a0f644a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +// The CLI is native-only; on wasm32 the binary is an empty stub so that +// whole-workspace builds (and CI) can target wasm32-unknown-unknown. +// Use the library's `generate` module for wasm. + +#[cfg(not(target_arch = "wasm32"))] +mod cli; + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::main] +async fn main() { + cli::main().await +} + +#[cfg(target_arch = "wasm32")] +fn main() {} From 5e93f36f7ad93b6a09b6a008e4229e7124e6b7f0 Mon Sep 17 00:00:00 2001 From: lin Date: Wed, 8 Jul 2026 19:34:43 -0400 Subject: [PATCH 4/4] ci: trigger