Build v0 example and SSE slices
This commit is contained in:
Generated
+107
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"hyper-util",
|
||||||
"itoa",
|
"itoa",
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -45,6 +46,7 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
"serde",
|
"serde",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -221,6 +223,42 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httpdate",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -237,6 +275,12 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -255,6 +299,17 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -418,6 +473,7 @@ name = "slhx-axum"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"futures-util",
|
||||||
"slhx-core",
|
"slhx-core",
|
||||||
"slhx-js",
|
"slhx-js",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -458,6 +514,31 @@ dependencies = [
|
|||||||
"slhx-core",
|
"slhx-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-v0-examples"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"slhx",
|
||||||
|
"slhx-build",
|
||||||
|
"slhx-test",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -513,8 +594,12 @@ version = "1.52.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -538,6 +623,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -594,6 +680,27 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test"]
|
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-v0-examples"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx = { path = "../../slhx" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
slhx-test = { path = "../../slhx-test" }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slhx-build = { path = "../../slhx-build" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fn main() { slhx_build::app().run().unwrap(); }
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#[slhx::surface]
|
||||||
|
pub mod ui {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ui;
|
||||||
|
use slhx::{push, Effect, IntoEffect, NavigateMode};
|
||||||
|
use slhx_test::inspect;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Todo {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct TodoInput {
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct WizardInput {
|
||||||
|
step: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Credentials {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: ceremony/001 req: build/001 req: build/005 req: codegen/002
|
||||||
|
#[test]
|
||||||
|
fn counter_updates_a_generated_slot() {
|
||||||
|
fn increment(count: u64) -> impl IntoEffect {
|
||||||
|
ui::slots::counter_value.text(count + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect = inspect(increment(1));
|
||||||
|
|
||||||
|
assert!(effect.has_slot(ui::slots::counter_value));
|
||||||
|
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
|
||||||
|
#[test]
|
||||||
|
fn todos_append_keyed_rows_from_form_input() {
|
||||||
|
fn add_todo(input: TodoInput) -> impl IntoEffect {
|
||||||
|
let todo = Todo { id: 7, title: input.title };
|
||||||
|
ui::slots::todo_row.append(todo.id.to_string(), format!("<li>{}</li>", todo.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect = inspect(add_todo(TodoInput { title: "Ship v0".into() }));
|
||||||
|
|
||||||
|
assert!(effect.has_resource(ui::slots::todo_row.id()));
|
||||||
|
assert!(matches!(effect.ops(), [Effect::Insert { key, .. }] if key == "7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||||
|
#[test]
|
||||||
|
fn wizard_form_swaps_the_current_step() {
|
||||||
|
fn next_step(input: WizardInput) -> impl IntoEffect {
|
||||||
|
ui::slots::wizard_step.text(format!("Step {}", input.step + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect = inspect(next_step(WizardInput { step: 1 }));
|
||||||
|
|
||||||
|
assert!(effect.has_slot(ui::slots::wizard_step));
|
||||||
|
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005
|
||||||
|
#[test]
|
||||||
|
fn page_swap_updates_content_and_history() {
|
||||||
|
fn load_docs() -> impl IntoEffect {
|
||||||
|
(
|
||||||
|
ui::slots::content.text("<h1>Docs</h1>"),
|
||||||
|
ui::slots::title.text("Docs"),
|
||||||
|
push("/docs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect = inspect(load_docs());
|
||||||
|
|
||||||
|
assert!(effect.has_slot(ui::slots::content));
|
||||||
|
assert!(effect.has_slot(ui::slots::title));
|
||||||
|
assert!(matches!(effect.ops().last(), Some(Effect::Navigate { url, mode: NavigateMode::Push, .. }) if url == "/docs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||||
|
#[test]
|
||||||
|
fn auth_flow_reads_typed_input_and_updates_status() {
|
||||||
|
fn login(input: Credentials) -> impl IntoEffect {
|
||||||
|
let status = if input.email == "demo@example.com" && !input.password.is_empty() {
|
||||||
|
"Signed in"
|
||||||
|
} else {
|
||||||
|
"Try again"
|
||||||
|
};
|
||||||
|
|
||||||
|
ui::slots::login_status.text(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect = inspect(login(Credentials { email: "demo@example.com".into(), password: "secret".into() }));
|
||||||
|
|
||||||
|
assert!(effect.has_slot(ui::slots::login_status));
|
||||||
|
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<section data-slhx-root="auth">
|
||||||
|
<form data-slhx-handle="login" data-slhx-form="credentials">
|
||||||
|
<input name="email" type="email" autocomplete="username">
|
||||||
|
<input name="password" type="password" autocomplete="current-password">
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<p data-slhx-slot="login_status">Signed out</p>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<section data-slhx-root="counter">
|
||||||
|
<output data-slhx-slot="counter_value">0</output>
|
||||||
|
<button type="button" data-slhx-handle="increment">+</button>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<main data-slhx-root="docs">
|
||||||
|
<nav data-slhx-slot="nav">
|
||||||
|
<a href="/docs" data-slhx-nav="" data-slhx-handle="load_docs">Docs</a>
|
||||||
|
</nav>
|
||||||
|
<article data-slhx-slot="content">Welcome</article>
|
||||||
|
<title data-slhx-slot="title">Welcome</title>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<section data-slhx-root="todos">
|
||||||
|
<form data-slhx-handle="add_todo" data-slhx-form="new_todo">
|
||||||
|
<input name="title" required="required">
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
<ul data-slhx-slot="todo_list">
|
||||||
|
<template h-for="todo in &self.todos" h-key="todo.id">
|
||||||
|
<li data-slhx-slot="todo_row">{+ todo.title +}</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<section data-slhx-root="wizard">
|
||||||
|
<form data-slhx-handle="next_step" data-slhx-form="wizard_input">
|
||||||
|
<div data-slhx-slot="wizard_step">Step 1</div>
|
||||||
|
<button type="submit">Next</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -7,7 +7,8 @@ edition.workspace = true
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", default-features = false, features = ["multipart"] }
|
axum = { version = "0.7", default-features = false, features = ["multipart", "tokio"] }
|
||||||
|
futures-util = { version = "0.3", default-features = false }
|
||||||
slhx-core = { path = "../slhx-core" }
|
slhx-core = { path = "../slhx-core" }
|
||||||
slhx-js = { path = "../slhx-js" }
|
slhx-js = { path = "../slhx-js" }
|
||||||
|
|
||||||
|
|||||||
+79
-2
@@ -2,7 +2,9 @@ use axum::async_trait;
|
|||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
||||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||||
|
use axum::response::sse::{Event, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
use futures_util::{Stream, StreamExt};
|
||||||
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
@@ -13,6 +15,7 @@ pub const SLHX_TITLE_HEADER: &str = "x-slhx-title";
|
|||||||
pub const SLHX_CONTENT_TYPE: &str = "application/slhx";
|
pub const SLHX_CONTENT_TYPE: &str = "application/slhx";
|
||||||
pub const SLHX_HANDLE_FIELD: &str = "__h";
|
pub const SLHX_HANDLE_FIELD: &str = "__h";
|
||||||
pub const SLHX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
|
pub const SLHX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
|
||||||
|
pub const SLHX_SSE_EVENT: &str = "slhx";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
pub struct RuntimeJs;
|
pub struct RuntimeJs;
|
||||||
@@ -426,6 +429,54 @@ pub fn runtime_js_source() -> &'static str {
|
|||||||
slhx_js::RUNTIME_JS
|
slhx_js::RUNTIME_JS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// req: push/001, req: push/003, req: push/004
|
||||||
|
pub fn sse<S, E>(batches: S) -> Sse<impl Stream<Item = Result<Event, E>> + Send>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
|
||||||
|
E: Into<axum::BoxError>,
|
||||||
|
{
|
||||||
|
Sse::new(batches.map(|batch| batch.map(sse_event)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sse_event(batch: EffectBatch) -> Event {
|
||||||
|
Event::default()
|
||||||
|
.event(SLHX_SSE_EVENT)
|
||||||
|
.data(encode_sse_batch(&batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_sse_batch(batch: &EffectBatch) -> String {
|
||||||
|
base64_url_no_pad(&batch.to_wire())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_url_no_pad(input: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
|
||||||
|
let mut chunks = input.chunks_exact(3);
|
||||||
|
for chunk in &mut chunks {
|
||||||
|
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
|
||||||
|
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[(n & 0x3f) as usize] as char);
|
||||||
|
}
|
||||||
|
match chunks.remainder() {
|
||||||
|
[a] => {
|
||||||
|
let n = (*a as u32) << 16;
|
||||||
|
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||||
|
}
|
||||||
|
[a, b] => {
|
||||||
|
let n = ((*a as u32) << 16) | ((*b as u32) << 8);
|
||||||
|
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||||
|
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
||||||
|
}
|
||||||
|
[] => {}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -479,8 +530,11 @@ fn hex(byte: u8) -> Option<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{html_with_root_fingerprint, BuildFingerprint, InteractionForm};
|
use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT};
|
||||||
use axum::{body::Body, extract::FromRequest, http::Request};
|
use axum::{body::{to_bytes, Body}, extract::FromRequest, http::{header, Request}, response::IntoResponse};
|
||||||
|
use futures_util::stream;
|
||||||
|
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn root_fingerprint_is_added_to_initial_root() {
|
fn root_fingerprint_is_added_to_initial_root() {
|
||||||
@@ -505,6 +559,29 @@ mod tests {
|
|||||||
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sse_response_streams_base64_url_effect_batches() {
|
||||||
|
let batch = EffectBatch {
|
||||||
|
abi_version: EFFECT_BATCH_ABI_VERSION,
|
||||||
|
fingerprint: BuildFingerprint(11),
|
||||||
|
ops: Vec::new(),
|
||||||
|
};
|
||||||
|
let encoded = encode_sse_batch(&batch);
|
||||||
|
|
||||||
|
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::CONTENT_TYPE).unwrap(),
|
||||||
|
"text/event-stream"
|
||||||
|
);
|
||||||
|
let body = to_bytes(response.into_body(), 1024).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(body.to_vec()).unwrap(),
|
||||||
|
format!("event: {SLHX_SSE_EVENT}\ndata: {encoded}\n\n")
|
||||||
|
);
|
||||||
|
assert!(!encoded.contains('='));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interaction_form_extracts_multipart_fields_and_files() {
|
async fn interaction_form_extracts_multipart_fields_and_files() {
|
||||||
let boundary = "slhx-test-boundary";
|
let boundary = "slhx-test-boundary";
|
||||||
|
|||||||
+113
-35
@@ -75,6 +75,7 @@ struct Resources {
|
|||||||
struct Resource {
|
struct Resource {
|
||||||
symbol: String,
|
symbol: String,
|
||||||
ident: String,
|
ident: String,
|
||||||
|
component: String,
|
||||||
keyed: bool,
|
keyed: bool,
|
||||||
id: u32,
|
id: u32,
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,7 @@ struct GeneratedControl {
|
|||||||
|
|
||||||
impl Resources {
|
impl Resources {
|
||||||
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
||||||
|
let component = component_ident(root, path)?;
|
||||||
for node in &surface.nodes {
|
for node in &surface.nodes {
|
||||||
let SurfaceNodeKind::Element { tag } = &node.kind else {
|
let SurfaceNodeKind::Element { tag } = &node.kind else {
|
||||||
continue;
|
continue;
|
||||||
@@ -103,19 +105,19 @@ impl Resources {
|
|||||||
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
||||||
let keyed = is_inside_keyed_for(surface, node.scope);
|
let keyed = is_inside_keyed_for(surface, node.scope);
|
||||||
let canonical = canonical_symbol(root, path, &name);
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
self.insert_slot(canonical, name, keyed)?;
|
self.insert_slot(canonical, name, component.clone(), keyed)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = static_attr(&node.attrs, "data-slhx-atom") {
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-atom") {
|
||||||
reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?;
|
reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?;
|
||||||
let canonical = canonical_symbol(root, path, &name);
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
self.insert_atom(canonical, name)?;
|
self.insert_atom(canonical, name, component.clone())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = static_attr(&node.attrs, "data-slhx-handle") {
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-handle") {
|
||||||
reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?;
|
reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?;
|
||||||
let canonical = canonical_symbol(root, path, &name);
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
self.insert_handle(canonical, name.clone())?;
|
self.insert_handle(canonical, name.clone(), component.clone())?;
|
||||||
|
|
||||||
if tag == "form" {
|
if tag == "form" {
|
||||||
let controls = surface
|
let controls = surface
|
||||||
@@ -133,32 +135,33 @@ impl Resources {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.insert_form(canonical_symbol(root, path, &name), name, controls)?;
|
self.insert_form(canonical_symbol(root, path, &name), name, component.clone(), controls)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_slot(&mut self, symbol: String, name: String, keyed: bool) -> io::Result<()> {
|
fn insert_slot(&mut self, symbol: String, name: String, component: String, keyed: bool) -> io::Result<()> {
|
||||||
insert_resource(&mut self.slots, "slot", symbol, name, keyed)
|
insert_resource(&mut self.slots, "slot", symbol, name, component, keyed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_handle(&mut self, symbol: String, name: String) -> io::Result<()> {
|
fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
|
||||||
insert_resource(&mut self.handles, "handle", symbol, name, false)
|
insert_resource(&mut self.handles, "handle", symbol, name, component, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_atom(&mut self, symbol: String, name: String) -> io::Result<()> {
|
fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
|
||||||
insert_resource(&mut self.atoms, "atom", symbol, name, false)
|
insert_resource(&mut self.atoms, "atom", symbol, name, component, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_form(
|
fn insert_form(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: String,
|
symbol: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
component: String,
|
||||||
controls: Vec<GeneratedControl>,
|
controls: Vec<GeneratedControl>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let resource = make_resource("form", symbol, name, false)?;
|
let resource = make_resource("form", symbol, name, component, false)?;
|
||||||
match self.forms.get(&resource.ident) {
|
match self.forms.get(&resource.ident) {
|
||||||
Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new(
|
Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
io::ErrorKind::InvalidData,
|
||||||
@@ -187,67 +190,111 @@ impl Resources {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
));
|
));
|
||||||
|
|
||||||
out.push_str("pub mod slots {\n");
|
// req: build/001
|
||||||
for res in self.slots.values() {
|
self.push_resource_modules(&mut out, None, 0);
|
||||||
|
|
||||||
|
for component in self.component_names() {
|
||||||
|
out.push_str("\n#[allow(non_upper_case_globals)]\n");
|
||||||
|
out.push_str(&format!("pub mod {component} {{\n"));
|
||||||
|
self.push_resource_modules(&mut out, Some(&component), 1);
|
||||||
|
out.push_str("}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_resource_modules(&self, out: &mut String, component: Option<&str>, indent: usize) {
|
||||||
|
let pad = " ".repeat(indent);
|
||||||
|
let inner = " ".repeat(indent + 1);
|
||||||
|
let mut handle_ids = Vec::new();
|
||||||
|
|
||||||
|
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod slots {{\n"));
|
||||||
|
for res in self.slots.values().filter(|res| component_matches(res, component)) {
|
||||||
if res.keyed {
|
if res.keyed {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n",
|
"{inner}pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n",
|
||||||
res.ident, res.id
|
res.ident, res.id
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::new({});\n",
|
"{inner}pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::new({});\n",
|
||||||
res.ident, res.id
|
res.ident, res.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push_str("}\n\n");
|
out.push_str(&format!("{pad}}}\n\n"));
|
||||||
|
|
||||||
out.push_str("pub mod handles {\n");
|
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n"));
|
||||||
for res in self.handles.values() {
|
for res in self.handles.values().filter(|res| component_matches(res, component)) {
|
||||||
|
handle_ids.push(res.id);
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}: ::slhx::Handle<()> = ::slhx::Handle::new({});\n",
|
"{inner}pub const {}: ::slhx::Handle<()> = ::slhx::Handle::new({});\n",
|
||||||
res.ident, res.id
|
res.ident, res.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push_str("}\n\n");
|
out.push_str(&format!(
|
||||||
|
"{inner}pub const ALL_IDS: &[u32] = &[{}];\n",
|
||||||
|
handle_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
out.push_str(&format!("{pad}}}\n\n"));
|
||||||
|
|
||||||
out.push_str("pub mod atoms {\n");
|
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n"));
|
||||||
for res in self.atoms.values() {
|
for res in self.atoms.values().filter(|res| component_matches(res, component)) {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n",
|
"{inner}pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n",
|
||||||
res.ident, res.id
|
res.ident, res.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push_str("}\n\n");
|
out.push_str(&format!("{pad}}}\n\n"));
|
||||||
|
|
||||||
out.push_str("pub mod forms {\n");
|
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n"));
|
||||||
for form in self.forms.values() {
|
for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) {
|
||||||
let res = &form.resource;
|
let res = &form.resource;
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n",
|
"{inner}pub const {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n",
|
||||||
res.ident, res.id
|
res.ident, res.id
|
||||||
));
|
));
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n",
|
"{inner}pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n",
|
||||||
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
|
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
|
||||||
));
|
));
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
|
"{inner}pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
|
||||||
res.ident.to_ascii_uppercase()
|
res.ident.to_ascii_uppercase()
|
||||||
));
|
));
|
||||||
for control in &form.controls {
|
for control in &form.controls {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
|
"{inner} ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
|
||||||
rust_str(&control.name),
|
rust_str(&control.name),
|
||||||
form_control_kind_expr(&control.kind),
|
form_control_kind_expr(&control.kind),
|
||||||
control.required
|
control.required
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push_str(" ];\n");
|
out.push_str(&format!("{inner}];\n"));
|
||||||
}
|
}
|
||||||
out.push_str("}\n");
|
out.push_str(&format!("{pad}}}\n"));
|
||||||
out
|
}
|
||||||
|
|
||||||
|
fn component_names(&self) -> Vec<String> {
|
||||||
|
let mut components = Vec::new();
|
||||||
|
for component in self
|
||||||
|
.slots
|
||||||
|
.values()
|
||||||
|
.map(|res| &res.component)
|
||||||
|
.chain(self.handles.values().map(|res| &res.component))
|
||||||
|
.chain(self.atoms.values().map(|res| &res.component))
|
||||||
|
.chain(self.forms.values().map(|form| &form.resource.component))
|
||||||
|
{
|
||||||
|
if !components.contains(component) {
|
||||||
|
components.push(component.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components.sort();
|
||||||
|
components
|
||||||
}
|
}
|
||||||
|
|
||||||
fn syms(&self) -> String {
|
fn syms(&self) -> String {
|
||||||
@@ -306,9 +353,10 @@ fn insert_resource(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
symbol: String,
|
symbol: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
component: String,
|
||||||
keyed: bool,
|
keyed: bool,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let resource = make_resource(kind, symbol, name, keyed)?;
|
let resource = make_resource(kind, symbol, name, component, keyed)?;
|
||||||
match map.get(&resource.ident) {
|
match map.get(&resource.ident) {
|
||||||
Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new(
|
Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
io::ErrorKind::InvalidData,
|
||||||
@@ -325,7 +373,13 @@ fn insert_resource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_resource(kind: &str, symbol: String, name: String, keyed: bool) -> io::Result<Resource> {
|
fn make_resource(
|
||||||
|
kind: &str,
|
||||||
|
symbol: String,
|
||||||
|
name: String,
|
||||||
|
component: String,
|
||||||
|
keyed: bool,
|
||||||
|
) -> io::Result<Resource> {
|
||||||
let ident = rust_ident(&name).ok_or_else(|| {
|
let ident = rust_ident(&name).ok_or_else(|| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
io::ErrorKind::InvalidData,
|
||||||
@@ -336,6 +390,7 @@ fn make_resource(kind: &str, symbol: String, name: String, keyed: bool) -> io::R
|
|||||||
Ok(Resource {
|
Ok(Resource {
|
||||||
symbol,
|
symbol,
|
||||||
ident,
|
ident,
|
||||||
|
component,
|
||||||
keyed,
|
keyed,
|
||||||
id,
|
id,
|
||||||
})
|
})
|
||||||
@@ -371,6 +426,13 @@ fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option<String> {
|
|||||||
.and_then(|attr| attr.value.clone())
|
.and_then(|attr| attr.value.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn component_matches(res: &Resource, component: Option<&str>) -> bool {
|
||||||
|
match component {
|
||||||
|
Some(component) => res.component == component,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
|
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
|
||||||
loop {
|
loop {
|
||||||
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
||||||
@@ -418,6 +480,20 @@ fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String {
|
|||||||
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
|
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn component_ident(root: &Path, path: &Path) -> io::Result<String> {
|
||||||
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||||
|
let stem = rel
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("invalid template path `{}`", path.display())))?;
|
||||||
|
rust_ident(stem).ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid template name `{stem}`; expected a Rust module identifier"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn rust_ident(name: &str) -> Option<String> {
|
fn rust_ident(name: &str) -> Option<String> {
|
||||||
let mut chars = name.chars();
|
let mut chars = name.chars();
|
||||||
let first = chars.next()?;
|
let first = chars.next()?;
|
||||||
@@ -510,6 +586,8 @@ mod tests {
|
|||||||
assert!(generated.contains("pub mod forms"));
|
assert!(generated.contains("pub mod forms"));
|
||||||
assert!(generated.contains("pub mod atoms"));
|
assert!(generated.contains("pub mod atoms"));
|
||||||
assert!(generated.contains("pub const filter"));
|
assert!(generated.contains("pub const filter"));
|
||||||
|
assert!(generated.contains("pub mod todo"));
|
||||||
|
assert!(generated.contains("pub const ALL_IDS"));
|
||||||
|
|
||||||
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
||||||
assert!(syms.contains("atom\t"));
|
assert!(syms.contains("atom\t"));
|
||||||
|
|||||||
Reference in New Issue
Block a user