diff --git a/Cargo.lock b/Cargo.lock
index cd26a75..a70d7cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -35,6 +35,7 @@ dependencies = [
"http",
"http-body",
"http-body-util",
+ "hyper-util",
"itoa",
"matchit",
"memchr",
@@ -45,6 +46,7 @@ dependencies = [
"rustversion",
"serde",
"sync_wrapper",
+ "tokio",
"tower",
"tower-layer",
"tower-service",
@@ -221,6 +223,42 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "indexmap"
version = "2.14.0"
@@ -237,6 +275,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
[[package]]
name = "matchit"
version = "0.7.3"
@@ -255,6 +299,17 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "multer"
version = "3.1.0"
@@ -418,6 +473,7 @@ name = "slhx-axum"
version = "0.1.0"
dependencies = [
"axum",
+ "futures-util",
"slhx-core",
"slhx-js",
"tokio",
@@ -458,6 +514,31 @@ dependencies = [
"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]]
name = "spin"
version = "0.9.8"
@@ -513,8 +594,12 @@ version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
+ "libc",
+ "mio",
"pin-project-lite",
+ "socket2",
"tokio-macros",
+ "windows-sys",
]
[[package]]
@@ -538,6 +623,7 @@ dependencies = [
"futures-util",
"pin-project-lite",
"sync_wrapper",
+ "tokio",
"tower-layer",
"tower-service",
]
@@ -594,6 +680,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "zmij"
version = "1.0.21"
diff --git a/Cargo.toml b/Cargo.toml
index 1c32467..584fb20 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
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]
version = "0.1.0"
diff --git a/examples/v0/Cargo.toml b/examples/v0/Cargo.toml
new file mode 100644
index 0000000..e4ed439
--- /dev/null
+++ b/examples/v0/Cargo.toml
@@ -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" }
diff --git a/examples/v0/build.rs b/examples/v0/build.rs
new file mode 100644
index 0000000..dadfeed
--- /dev/null
+++ b/examples/v0/build.rs
@@ -0,0 +1 @@
+fn main() { slhx_build::app().run().unwrap(); }
diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs
new file mode 100644
index 0000000..363b031
--- /dev/null
+++ b/examples/v0/src/lib.rs
@@ -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!("
{}", 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("Docs
"),
+ 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 { .. }]));
+ }
+}
diff --git a/examples/v0/templates/auth.heml b/examples/v0/templates/auth.heml
new file mode 100644
index 0000000..a8861ee
--- /dev/null
+++ b/examples/v0/templates/auth.heml
@@ -0,0 +1,8 @@
+
diff --git a/examples/v0/templates/counter.heml b/examples/v0/templates/counter.heml
new file mode 100644
index 0000000..0dea6a9
--- /dev/null
+++ b/examples/v0/templates/counter.heml
@@ -0,0 +1,4 @@
+
diff --git a/examples/v0/templates/page_swap.heml b/examples/v0/templates/page_swap.heml
new file mode 100644
index 0000000..6ddbb54
--- /dev/null
+++ b/examples/v0/templates/page_swap.heml
@@ -0,0 +1,7 @@
+
+
+ Welcome
+ Welcome
+
diff --git a/examples/v0/templates/todos.heml b/examples/v0/templates/todos.heml
new file mode 100644
index 0000000..1438635
--- /dev/null
+++ b/examples/v0/templates/todos.heml
@@ -0,0 +1,11 @@
+
diff --git a/examples/v0/templates/wizard.heml b/examples/v0/templates/wizard.heml
new file mode 100644
index 0000000..51c6825
--- /dev/null
+++ b/examples/v0/templates/wizard.heml
@@ -0,0 +1,6 @@
+
diff --git a/slhx-axum/Cargo.toml b/slhx-axum/Cargo.toml
index 66a2373..81514b0 100644
--- a/slhx-axum/Cargo.toml
+++ b/slhx-axum/Cargo.toml
@@ -7,7 +7,8 @@ edition.workspace = true
path = "src/lib.rs"
[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-js = { path = "../slhx-js" }
diff --git a/slhx-axum/src/lib.rs b/slhx-axum/src/lib.rs
index bf5809e..075c18d 100644
--- a/slhx-axum/src/lib.rs
+++ b/slhx-axum/src/lib.rs
@@ -2,7 +2,9 @@ use axum::async_trait;
use axum::body::{to_bytes, Body};
use axum::extract::{FromRequest, FromRequestParts, Multipart};
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
+use axum::response::sse::{Event, Sse};
use axum::response::IntoResponse;
+use futures_util::{Stream, StreamExt};
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
use std::collections::BTreeMap;
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_HANDLE_FIELD: &str = "__h";
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)]
pub struct RuntimeJs;
@@ -426,6 +429,54 @@ pub fn runtime_js_source() -> &'static str {
slhx_js::RUNTIME_JS
}
+// req: push/001, req: push/003, req: push/004
+pub fn sse(batches: S) -> Sse> + Send>
+where
+ S: Stream- > + Send + 'static,
+ E: Into,
+{
+ 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, InteractionFormRejection> {
if body.is_empty() {
return Ok(Vec::new());
@@ -479,8 +530,11 @@ fn hex(byte: u8) -> Option {
#[cfg(test)]
mod tests {
- use super::{html_with_root_fingerprint, BuildFingerprint, InteractionForm};
- use axum::{body::Body, extract::FromRequest, http::Request};
+ use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT};
+ 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]
fn root_fingerprint_is_added_to_initial_root() {
@@ -505,6 +559,29 @@ mod tests {
assert_eq!(html, "Docs");
}
+ #[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]
async fn interaction_form_extracts_multipart_fields_and_files() {
let boundary = "slhx-test-boundary";
diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs
index 7a25f7d..13ed9bb 100644
--- a/slhx-build/src/lib.rs
+++ b/slhx-build/src/lib.rs
@@ -75,6 +75,7 @@ struct Resources {
struct Resource {
symbol: String,
ident: String,
+ component: String,
keyed: bool,
id: u32,
}
@@ -94,6 +95,7 @@ struct GeneratedControl {
impl Resources {
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
+ let component = component_ident(root, path)?;
for node in &surface.nodes {
let SurfaceNodeKind::Element { tag } = &node.kind else {
continue;
@@ -103,19 +105,19 @@ impl Resources {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(surface, node.scope);
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") {
reject_unkeyed_loop(surface, node.scope, path, "atom", &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") {
reject_unkeyed_loop(surface, node.scope, path, "handle", &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" {
let controls = surface
@@ -133,32 +135,33 @@ impl Resources {
.collect()
})
.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(())
}
- fn insert_slot(&mut self, symbol: String, name: String, keyed: bool) -> io::Result<()> {
- insert_resource(&mut self.slots, "slot", symbol, name, keyed)
+ fn insert_slot(&mut self, symbol: String, name: String, component: String, keyed: bool) -> io::Result<()> {
+ insert_resource(&mut self.slots, "slot", symbol, name, component, keyed)
}
- fn insert_handle(&mut self, symbol: String, name: String) -> io::Result<()> {
- insert_resource(&mut self.handles, "handle", symbol, name, false)
+ fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
+ insert_resource(&mut self.handles, "handle", symbol, name, component, false)
}
- fn insert_atom(&mut self, symbol: String, name: String) -> io::Result<()> {
- insert_resource(&mut self.atoms, "atom", symbol, name, false)
+ fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
+ insert_resource(&mut self.atoms, "atom", symbol, name, component, false)
}
fn insert_form(
&mut self,
symbol: String,
name: String,
+ component: String,
controls: Vec,
) -> 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) {
Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -187,67 +190,111 @@ impl Resources {
.join(", ")
));
- out.push_str("pub mod slots {\n");
- for res in self.slots.values() {
+ // req: build/001
+ 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 {
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
));
} else {
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
));
}
}
- out.push_str("}\n\n");
+ out.push_str(&format!("{pad}}}\n\n"));
- out.push_str("pub mod handles {\n");
- for res in self.handles.values() {
+ out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n"));
+ for res in self.handles.values().filter(|res| component_matches(res, component)) {
+ handle_ids.push(res.id);
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
));
}
- 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::>()
+ .join(", ")
+ ));
+ out.push_str(&format!("{pad}}}\n\n"));
- out.push_str("pub mod atoms {\n");
- for res in self.atoms.values() {
+ out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n"));
+ for res in self.atoms.values().filter(|res| component_matches(res, component)) {
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
));
}
- out.push_str("}\n\n");
+ out.push_str(&format!("{pad}}}\n\n"));
- out.push_str("pub mod forms {\n");
- for form in self.forms.values() {
+ out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n"));
+ for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) {
let res = &form.resource;
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
));
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()
));
out.push_str(&format!(
- " pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
+ "{inner}pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
res.ident.to_ascii_uppercase()
));
for control in &form.controls {
out.push_str(&format!(
- " ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
+ "{inner} ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
rust_str(&control.name),
form_control_kind_expr(&control.kind),
control.required
));
}
- out.push_str(" ];\n");
+ out.push_str(&format!("{inner}];\n"));
}
- out.push_str("}\n");
- out
+ out.push_str(&format!("{pad}}}\n"));
+ }
+
+ fn component_names(&self) -> Vec {
+ 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 {
@@ -306,9 +353,10 @@ fn insert_resource(
kind: &str,
symbol: String,
name: String,
+ component: String,
keyed: bool,
) -> io::Result<()> {
- let resource = make_resource(kind, symbol, name, keyed)?;
+ let resource = make_resource(kind, symbol, name, component, keyed)?;
match map.get(&resource.ident) {
Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -325,7 +373,13 @@ fn insert_resource(
}
}
-fn make_resource(kind: &str, symbol: String, name: String, keyed: bool) -> io::Result {
+fn make_resource(
+ kind: &str,
+ symbol: String,
+ name: String,
+ component: String,
+ keyed: bool,
+) -> io::Result {
let ident = rust_ident(&name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
@@ -336,6 +390,7 @@ fn make_resource(kind: &str, symbol: String, name: String, keyed: bool) -> io::R
Ok(Resource {
symbol,
ident,
+ component,
keyed,
id,
})
@@ -371,6 +426,13 @@ fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option {
.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 {
loop {
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('\\', "/"))
}
+fn component_ident(root: &Path, path: &Path) -> io::Result {
+ 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 {
let mut chars = name.chars();
let first = chars.next()?;
@@ -510,6 +586,8 @@ mod tests {
assert!(generated.contains("pub mod forms"));
assert!(generated.contains("pub mod atoms"));
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();
assert!(syms.contains("atom\t"));