feat(build): emit checked component refs

Generate ComponentRef constants for component-scoped APIs, include global component refs only when global_exports(true) is enabled, and document/use the checked component token.

req: component/003
This commit is contained in:
slhx agent
2026-05-26 00:35:59 +02:00
parent a421865852
commit efabd3ff66
5 changed files with 73 additions and 6 deletions
+2 -2
View File
@@ -166,8 +166,8 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
### req: component/003
003 Generated APIs are component-namespaced by default:
`ui::slots::todo_row`, `ui::handles::create`, `ui::forms::create`.
Global exports are opt-in only.
`ui::todo_list::slots::todo_row`, `ui::todo_list::handles::create`, `ui::todo_list::forms::create`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`.
Global exports (`ui::slots::*`, `ui::handles::*`, `ui::components::*`) are opt-in only.
### req: component/004
004 `#[slhx::surface]` bridges generated code into a user module. Users write `#[slhx::surface] mod ui {}` instead of `include!(concat!(env!("OUT_DIR"), ...))`. slhx-build emits `slhx.generated.rs` which the macro expands in place. No direct `$OUT_DIR` includes in user-authored source.
+29
View File
@@ -330,6 +330,7 @@ impl Resources {
// req: build/001 req: component/003
if global_exports {
self.push_component_refs(&mut out, 0);
self.push_resource_modules(&mut out, None, 0);
self.push_lowering_api(&mut out, None, 0);
}
@@ -346,11 +347,31 @@ impl Resources {
out
}
fn push_component_refs(&self, out: &mut String, indent: usize) {
let pad = " ".repeat(indent);
let inner = " ".repeat(indent + 1);
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod components {{\n"));
for component in self.component_names() {
out.push_str(&format!(
"{inner}pub const {component}: ::slhx::ComponentRef = ::slhx::ComponentRef::new({});\n",
rust_str(&component)
));
}
out.push_str(&format!("{pad}}}\n"));
}
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();
if let Some(component) = component {
out.push_str(&format!(
"{pad}pub const COMPONENT: ::slhx::ComponentRef = ::slhx::ComponentRef::new({});\n",
rust_str(component)
));
}
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 {
@@ -1035,6 +1056,8 @@ mod tests {
app().template_dir(&templates).out_dir(&out).global_exports(true).run().unwrap();
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
assert!(generated.contains("pub mod components"));
assert!(generated.contains("pub const todo: ::slhx::ComponentRef = ::slhx::ComponentRef::new(\"todo\")"));
assert!(generated.contains("pub mod slots"));
assert!(generated.contains("pub const todos"));
assert!(generated.contains("pub mod handles"));
@@ -1084,9 +1107,11 @@ mod tests {
app().template_dir(&templates).out_dir(&out).run().unwrap();
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod components"));
assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod slots"));
assert!(!generated.contains("\npub fn lower_html"));
assert!(generated.contains("pub mod todo"));
assert!(generated.contains(" pub const COMPONENT: ::slhx::ComponentRef = ::slhx::ComponentRef::new(\"todo\")"));
assert!(generated.contains(" pub mod slots"));
assert!(generated.contains(" pub fn lower_html"));
@@ -1201,6 +1226,8 @@ mod slhx {{
impl<T> Form<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct CssClass(&'static str);
impl CssClass {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
#[derive(Clone, Copy)] pub struct ComponentRef(&'static str);
impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
pub struct FormContract {{ pub fields: &'static [FormField] }}
pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }}
pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }}
@@ -1292,6 +1319,8 @@ mod slhx {{
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
impl<T> Form<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct ComponentRef(&'static str);
impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
pub struct FormContract {{ pub fields: &'static [FormField] }}
pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }}
pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }}
+29
View File
@@ -186,6 +186,35 @@ impl core::fmt::Display for CssClass {
}
}
/// A generated, checked component token.
/// req: component/003
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ComponentRef {
name: &'static str,
}
impl ComponentRef {
pub const fn new(name: &'static str) -> Self {
Self { name }
}
pub const fn as_str(self) -> &'static str {
self.name
}
}
impl AsRef<str> for ComponentRef {
fn as_ref(&self) -> &str {
self.name
}
}
impl core::fmt::Display for ComponentRef {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.name)
}
}
/// A generated, checked event token.
/// req: codegen/006
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+10 -2
View File
@@ -1,7 +1,7 @@
use slhx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot, NavigateMode,
Payload, ResourceKind, SafeHtml, ScopeKey, Slot,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
NavigateMode, Payload, ResourceKind, SafeHtml, ScopeKey, Slot,
};
#[test]
@@ -93,6 +93,14 @@ fn slot_html_requires_explicit_safe_html() {
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
}
#[test]
fn component_refs_format_generated_component_names() {
// req: component/003
let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.to_string(), "todo_list");
}
#[test]
fn generated_handles_format_for_hemplate_dynamic_handle_attrs() {
// req: public_api/001 req: public_api/002
+3 -2
View File
@@ -8,8 +8,9 @@ pub use slhx_derive::{app, component, form, handler, surface};
pub mod prelude {
pub use slhx_core::{
navigate, push, redirect, replace, Atom, BuildFingerprint, CssClass, CssClasses, Effect,
EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot, Slot,
navigate, push, redirect, replace, Atom, BuildFingerprint, ComponentRef, CssClass,
CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot,
Slot,
};
pub use slhx_derive::{app, component, form, handler, surface};
}