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 @@ +
    +
    + + + +
    +

    Signed out

    +
    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 @@ +
    + 0 + +
    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 @@ +
    +
    +
    Step 1
    + +
    +
    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"));