Skip to content

Commit bb47abf

Browse files
feat: Website with live demo
1 parent 44bc882 commit bb47abf

34 files changed

Lines changed: 1878 additions & 30 deletions

‎.github/workflows/pages.yml‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
# The site is a prerendered SvelteKit page that embeds the calculator core
7+
# as WebAssembly, so a core change is a site change.
8+
paths:
9+
- "website/**"
10+
- "wasm/**"
11+
- "core/**"
12+
- "Cargo.toml"
13+
- "Cargo.lock"
14+
- ".github/workflows/pages.yml"
15+
workflow_dispatch:
16+
17+
permissions:
18+
contents: read
19+
pages: write
20+
id-token: write
21+
22+
# Let an in-flight deployment finish rather than cancelling it: a cancelled
23+
# deploy-pages run can leave the live site on the previous commit.
24+
concurrency:
25+
group: pages
26+
cancel-in-progress: false
27+
28+
jobs:
29+
build:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: dtolnay/rust-toolchain@stable
34+
- uses: Swatinem/rust-cache@v2
35+
with:
36+
workspaces: ". -> target"
37+
38+
# Pinned to the version the wasm crate's wasm-opt flags were tuned for.
39+
- uses: taiki-e/install-action@v2
40+
with:
41+
tool: wasm-pack@0.12.1
42+
43+
- uses: oven-sh/setup-bun@v2
44+
with:
45+
bun-version: latest
46+
47+
- name: Install dependencies
48+
working-directory: website
49+
run: bun install --frozen-lockfile
50+
51+
# `bun run build` runs wasm-pack first, then vite.
52+
- name: Build the site
53+
working-directory: website
54+
run: bun run build
55+
56+
- name: Check the build output
57+
shell: bash
58+
run: |
59+
set -euo pipefail
60+
test -f website/build/index.html
61+
test -f website/build/CNAME
62+
# adapter-static emits the wasm module as a hashed asset; a missing one
63+
# means the demo would load into a dead calculator.
64+
test -n "$(find website/build/_app -name '*.wasm' -print -quit)"
65+
66+
- uses: actions/configure-pages@v5
67+
- uses: actions/upload-pages-artifact@v3
68+
with:
69+
path: website/build
70+
71+
deploy:
72+
needs: build
73+
runs-on: ubuntu-latest
74+
environment:
75+
name: github-pages
76+
url: ${{ steps.deployment.outputs.page_url }}
77+
steps:
78+
- id: deployment
79+
uses: actions/deploy-pages@v4

‎Cargo.lock‎

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"core",
66
"conformance",
77
"desktop",
8+
"wasm",
89
]
910

1011
[profile.release]

‎core/Cargo.toml‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ mendeleev = { version = "=0.8.4", default-features = false, features = [
1818
astro-float = { version = "0.9.6", default-features = false, features = [
1919
"std",
2020
] }
21-
# Dynamic (currency) units: live rates from the Frankfurter API, cached to disk.
21+
serde = { version = "1", features = ["derive"] }
22+
serde_json = "1"
23+
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system"] }
24+
25+
# Dynamic (currency) units fetch live rates and cache them to disk. Neither is
26+
# possible on wasm, where the host installs rates instead.
27+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
2228
# Minimal reqwest: no default features (drops http2/h2, native-tls, system-proxy,
2329
# charset) and `rustls-no-provider` so we supply the lighter `ring` crypto
2430
# provider instead of the large aws-lc-rs C library. JSON is parsed via
@@ -28,7 +34,8 @@ reqwest = { version = "0.13", default-features = false, features = [
2834
"rustls-no-provider",
2935
] }
3036
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
31-
serde = { version = "1", features = ["derive"] }
32-
serde_json = "1"
3337
dirs = "6"
34-
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system"] }
38+
39+
# jiff reads the clock and the local zone through JavaScript on wasm.
40+
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
41+
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system", "js"] }

‎core/src/representations/dimension/currency.rs‎

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
//! Dynamic (currency) units.
22
//!
33
//! Currencies are resolved at evaluation time using the [Frankfurter](https://frankfurter.dev) API.
4-
//! Exchange rates are cached to disk and refresh at most once an hour.
4+
//! Exchange rates are cached to disk and refresh at most once an hour. On wasm, where neither the
5+
//! network nor the disk is reachable from here, the host installs rates with
6+
//! [`install_static_rates`] instead.
57
68
use crate::representations::Float;
7-
use serde::{Deserialize, Serialize};
9+
#[cfg(not(target_arch = "wasm32"))]
10+
use serde::Deserialize;
11+
use serde::Serialize;
812
use std::collections::BTreeMap;
13+
#[cfg(not(target_arch = "wasm32"))]
914
use std::path::PathBuf;
1015
use std::sync::{Mutex, OnceLock};
11-
use std::time::{Duration, SystemTime, UNIX_EPOCH};
16+
#[cfg(not(target_arch = "wasm32"))]
17+
use std::time::{SystemTime, UNIX_EPOCH};
1218

19+
#[cfg(not(target_arch = "wasm32"))]
1320
const FRANKFURTER_URL: &str = "https://api.frankfurter.dev/v1/latest";
21+
#[cfg(not(target_arch = "wasm32"))]
1422
const CACHE_TTL_SECS: u64 = 3600;
15-
const FETCH_TIMEOUT: Duration = Duration::from_secs(8);
23+
#[cfg(not(target_arch = "wasm32"))]
24+
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
25+
#[cfg(not(target_arch = "wasm32"))]
1626
const RETRY_BACKOFF_SECS: u64 = 60;
1727

1828
const CANONICAL: &str = "EUR";
@@ -103,36 +113,34 @@ fn currency_name(code: &str) -> &'static str {
103113
}
104114
}
105115

116+
#[cfg(not(target_arch = "wasm32"))]
106117
#[derive(Deserialize)]
107118
struct FrankfurterResponse {
108119
rates: BTreeMap<String, f64>,
109120
}
110121

111-
#[derive(Serialize, Deserialize)]
122+
#[derive(Serialize)]
123+
#[cfg_attr(not(target_arch = "wasm32"), derive(Deserialize))]
112124
struct CachedRates {
113125
fetched_at: u64,
114126
rates: BTreeMap<String, f64>,
115127
}
116128

129+
#[derive(Default)]
117130
struct State {
118131
table: Option<CachedRates>,
119132
override_rates: Option<BTreeMap<String, f64>>,
133+
#[cfg(not(target_arch = "wasm32"))]
120134
loaded_disk: bool,
135+
#[cfg(not(target_arch = "wasm32"))]
121136
refreshing: bool,
137+
#[cfg(not(target_arch = "wasm32"))]
122138
last_attempt: u64,
123139
}
124140

125141
fn state() -> &'static Mutex<State> {
126142
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
127-
STATE.get_or_init(|| {
128-
Mutex::new(State {
129-
table: None,
130-
override_rates: None,
131-
loaded_disk: false,
132-
refreshing: false,
133-
last_attempt: 0,
134-
})
135-
})
143+
STATE.get_or_init(|| Mutex::new(State::default()))
136144
}
137145

138146
pub fn install_static_rates(pairs: &[(&str, f64)]) {
@@ -141,37 +149,43 @@ pub fn install_static_rates(pairs: &[(&str, f64)]) {
141149
state().lock().unwrap().override_rates = Some(rates);
142150
}
143151

152+
#[cfg(not(target_arch = "wasm32"))]
144153
fn now() -> u64 {
145154
SystemTime::now()
146155
.duration_since(UNIX_EPOCH)
147156
.map(|d| d.as_secs())
148157
.unwrap_or(0)
149158
}
150159

160+
#[cfg(not(target_arch = "wasm32"))]
151161
fn is_fresh(table: &Option<CachedRates>) -> bool {
152162
table
153163
.as_ref()
154164
.is_some_and(|t| now().saturating_sub(t.fetched_at) < CACHE_TTL_SECS)
155165
}
156166

157167
fn rate(code: &str) -> Option<Float> {
168+
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
158169
let mut state = state().lock().unwrap();
159170

160171
if let Some(rates) = &state.override_rates {
161172
return rates.get(code).and_then(|r| to_float(*r));
162173
}
163174

164-
if !state.loaded_disk {
165-
state.loaded_disk = true;
166-
state.table = load_disk();
167-
}
175+
#[cfg(not(target_arch = "wasm32"))]
176+
{
177+
if !state.loaded_disk {
178+
state.loaded_disk = true;
179+
state.table = load_disk();
180+
}
168181

169-
let stale = !is_fresh(&state.table);
170-
let backoff_elapsed = now().saturating_sub(state.last_attempt) >= RETRY_BACKOFF_SECS;
171-
if stale && !state.refreshing && backoff_elapsed {
172-
state.last_attempt = now();
173-
state.refreshing = true;
174-
spawn_refresh();
182+
let stale = !is_fresh(&state.table);
183+
let backoff_elapsed = now().saturating_sub(state.last_attempt) >= RETRY_BACKOFF_SECS;
184+
if stale && !state.refreshing && backoff_elapsed {
185+
state.last_attempt = now();
186+
state.refreshing = true;
187+
spawn_refresh();
188+
}
175189
}
176190

177191
state
@@ -182,6 +196,7 @@ fn rate(code: &str) -> Option<Float> {
182196
.and_then(|r| to_float(*r))
183197
}
184198

199+
#[cfg(not(target_arch = "wasm32"))]
185200
fn spawn_refresh() {
186201
std::thread::spawn(|| {
187202
let fetched = fetch();
@@ -205,6 +220,7 @@ fn to_float(rate: f64) -> Option<Float> {
205220
Float::parse(&format!("{rate}")).ok()
206221
}
207222

223+
#[cfg(not(target_arch = "wasm32"))]
208224
fn fetch() -> Option<BTreeMap<String, f64>> {
209225
ensure_tls_provider();
210226
let client = reqwest::blocking::Client::builder()
@@ -218,6 +234,7 @@ fn fetch() -> Option<BTreeMap<String, f64>> {
218234
Some(rates)
219235
}
220236

237+
#[cfg(not(target_arch = "wasm32"))]
221238
fn ensure_tls_provider() {
222239
use std::sync::Once;
223240
static ONCE: Once = Once::new();
@@ -226,18 +243,21 @@ fn ensure_tls_provider() {
226243
});
227244
}
228245

246+
#[cfg(not(target_arch = "wasm32"))]
229247
fn cache_path() -> Option<PathBuf> {
230248
let mut path = dirs::cache_dir()?;
231249
path.push("siffra");
232250
path.push("frankfurter.json");
233251
Some(path)
234252
}
235253

254+
#[cfg(not(target_arch = "wasm32"))]
236255
fn load_disk() -> Option<CachedRates> {
237256
let data = std::fs::read_to_string(cache_path()?).ok()?;
238257
serde_json::from_str(&data).ok()
239258
}
240259

260+
#[cfg(not(target_arch = "wasm32"))]
241261
fn save_disk(table: &CachedRates) {
242262
let Some(path) = cache_path() else {
243263
return;

‎core/src/representations/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod expression;
55
mod float;
66
mod format;
77
pub mod ladder;
8+
mod number_display;
89
mod value;
910

1011
pub use basis::{Basis, CalendarReadings};
@@ -13,4 +14,5 @@ pub use display::{DisplayHints, Granularity};
1314
pub use expression::*;
1415
pub use float::*;
1516
pub use format::{FormattedValue, format_value};
17+
pub use number_display::{NumberDisplay, format_number_for_characters, format_repeating_decimal};
1618
pub use value::*;
File renamed without changes.
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
use self::format::{NumberDisplay, format_number_for_characters, format_repeating_decimal};
21
use super::layout::{Fonts, Metrics, row_rects};
32
use crate::calculation::LineOutput;
3+
use siffra::representations::{
4+
NumberDisplay, format_number_for_characters, format_repeating_decimal,
5+
};
46

5-
pub mod format;
67
use crate::ui::theme;
78
use gpui::{
89
App, Bounds, Element, ElementId, Font, GlobalElementId, Hsla, InspectorElementId, IntoElement,

‎wasm/Cargo.toml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "siffra-wasm"
3+
version.workspace = true
4+
edition.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
publish = false
9+
10+
[lib]
11+
crate-type = ["cdylib", "rlib"]
12+
13+
# wasm-pack's bundled wasm-opt predates the features rustc now emits by default.
14+
[package.metadata.wasm-pack.profile.release]
15+
wasm-opt = [
16+
"-O",
17+
"--enable-bulk-memory",
18+
"--enable-nontrapping-float-to-int",
19+
"--enable-sign-ext",
20+
"--enable-mutable-globals",
21+
"--enable-reference-types",
22+
]
23+
24+
[dependencies]
25+
siffra = { path = "../core" }
26+
wasm-bindgen = "0.2"
27+
serde = { version = "1", features = ["derive"] }
28+
serde-wasm-bindgen = "0.6"

0 commit comments

Comments
 (0)