Build v0 example and SSE slices

This commit is contained in:
slhx agent
2026-05-10 22:26:54 +02:00
parent f33b306ec0
commit 3e0ed95490
13 changed files with 464 additions and 39 deletions
Generated
+107
View File
@@ -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"
+1 -1
View File
@@ -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"
+17
View File
@@ -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" }
+1
View File
@@ -0,0 +1 @@
fn main() { slhx_build::app().run().unwrap(); }
+108
View File
@@ -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 { .. }]));
}
}
+8
View File
@@ -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>
+4
View File
@@ -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>
+7
View File
@@ -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>
+11
View File
@@ -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>
+6
View File
@@ -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>
+2 -1
View File
@@ -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" }
+79 -2
View File
@@ -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<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> {
if body.is_empty() {
return Ok(Vec::new());
@@ -479,8 +530,11 @@ fn hex(byte: u8) -> Option<u8> {
#[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, "<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]
async fn interaction_form_extracts_multipart_fields_and_files() {
let boundary = "slhx-test-boundary";
+114 -36
View File
@@ -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<GeneratedControl>,
) -> 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("pub mod atoms {\n");
for res in self.atoms.values() {
out.push_str(&format!(
" pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n",
"{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(&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!(
"{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<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 {
@@ -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<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(|| {
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<String> {
.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<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> {
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"));