feat(style): generate checked class tokens

Add a boring CSS/SCSS class-token surface: slhx-build discovers static class tokens from hemplate/templates/stylesheets and emits generated CssClass constants. Add CssClasses for hemplate +class/view data and migrate techdemo dynamic/effect fragments toward hemplate-owned views with DOM-aware assertions.

req: style/001

req: style/002

req: style/003

req: html_safety/002

req: view/001

req: test/005
This commit is contained in:
slhx agent
2026-05-25 20:48:31 +02:00
parent 9ceea7de94
commit fee9762069
13 changed files with 638 additions and 54 deletions
+16
View File
@@ -599,6 +599,9 @@ what a valid business email is.
### req: test/004
004 Repository-wide verification uses a resource-aware runner that caps Cargo build jobs and Rust test threads from available CPU and memory. User-requested concurrency cannot exceed the detected safe cap. Browser E2E runs as an isolated step and can be skipped explicitly when browser infrastructure is unavailable.
### req: test/005
005 Tests that inspect rendered HTML structure, attributes, escaping, or ordering use DOM-aware parsing such as `scraper` or existing local HTML parsing helpers. Raw string assertions are reserved for tiny literal payload checks where parsing would add noise. [north_star]
---
## check
@@ -839,6 +842,19 @@ All forms support returning `impl IntoEffect` and compose through tuples.
---
## style
### req: style/001
001 Plain CSS and SCSS own appearance. slhx-build discovers static class tokens from `.heml`, `.css`, and `.scss` build inputs and generates `CssClass` constants so Rust can reference known classes without raw strings. slhx does not parse selectors for behavior, cascade policy, or layout semantics. [north_star]
### req: style/002
002 Generated class constants are ergonomic references only: they do not create a CSS framework, require a framework project structure, or make dynamic class expressions compile-time facts. Unknown Rust class references fail by normal Rust name resolution when the generated constant is absent. [north_star]
### req: style/003
003 When a hemplate dynamic class attribute needs more than one class token, Rust passes a displayable list of generated `CssClass` values as view data. Rust should not assemble ad hoc class strings for known style tokens. [north_star]
---
## convention
### req: convention/001
+223 -50
View File
@@ -4,8 +4,11 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{IntoEffect, SafeHtml};
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml};
use slhx_axum::{
runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
PageRequest,
};
use slhx_techdemo::ui;
use std::collections::{BTreeMap, VecDeque};
use std::convert::Infallible;
@@ -97,7 +100,7 @@ struct Shared {
#[derive(Hemplate)]
#[hemplate = "partials"]
struct IssueLane {
class: String,
class: CssClass,
lane_id: &'static str,
move_handle: u32,
title: &'static str,
@@ -108,7 +111,7 @@ struct IssueLane {
#[derive(Hemplate)]
#[hemplate = "partials"]
struct IssueCard {
class: String,
class: CssClasses,
id: u64,
title: String,
stage: &'static str,
@@ -138,6 +141,34 @@ struct InspectorSelected {
#[hemplate = "partials"]
struct InspectorEmpty;
#[derive(Hemplate)]
#[hemplate = "partials"]
struct LiveFeed {
tick: u64,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ActivityFeed {
items: Vec<String>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ArchitectureActivity;
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ArchitectureInspector;
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ArchitectureHero;
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ArchitectureBoard;
#[tokio::main]
async fn main() {
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) });
@@ -169,10 +200,10 @@ async fn home(State(state): State<Arc<Shared>>, request: PageRequest) -> impl In
// req: page_swap/001 req: page_swap/002 req: examples/001
async fn architecture(request: PageRequest) -> impl IntoResponse {
let body = ui::control_center::lower_html(include_str!("../templates/control_center.heml"))
.replace("__HERO__", &architecture_hero())
.replace("__BOARD__", &architecture_board())
.replace("__INSPECTOR__", &architecture_inspector())
.replace("__ACTIVITY__", &architecture_activity());
.replace("__HERO__", architecture_hero().as_str())
.replace("__BOARD__", architecture_board().as_str())
.replace("__INSPECTOR__", architecture_inspector().as_str())
.replace("__ACTIVITY__", architecture_activity().as_str());
request
.page(body, shell)
.title("slhx Architecture")
@@ -193,13 +224,13 @@ async fn interact(
// req: push/001 req: push/003 req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") {
let effect = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(1)));
let effect = ui::control_center::slots::live_feed.html(live_feed(1));
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
}
let batches = stream::unfold(1_u64, |tick| async move {
tokio::time::sleep(Duration::from_secs(4)).await;
let effect = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(tick)));
let effect = ui::control_center::slots::live_feed.html(live_feed(tick));
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1))
})
.boxed();
@@ -308,8 +339,8 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
let mut demo = shared.demo.lock().unwrap();
demo.log("Simulated push event produced the same EffectBatch shape");
(
ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(demo.activity.len() as u64))),
ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(&demo))),
ui::control_center::slots::live_feed.html(live_feed(demo.activity.len() as u64)),
ui::control_center::slots::activity.html(render_activity(&demo)),
ui::control_center::slots::notice.text("Push simulated · no client app code"),
)
}
@@ -337,7 +368,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
(
ui::control_center::slots::hero_metrics.html(SafeHtml::trusted(render_hero(demo))),
ui::control_center::slots::board.html(SafeHtml::trusted(render_board(demo))),
ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(demo))),
ui::control_center::slots::activity.html(render_activity(demo)),
ui::control_center::slots::inspector.html(SafeHtml::trusted(render_inspector(demo))),
ui::control_center::slots::notice.text(notice),
ui::control_center::forms::launch_work.clear("title"),
@@ -354,10 +385,11 @@ fn page_html(demo: &DemoState) -> String {
.replace("__HERO__", &render_hero(demo))
.replace("__BOARD__", &render_board(demo))
.replace("__INSPECTOR__", &render_inspector(demo))
.replace("__ACTIVITY__", &render_activity(demo))
.replace("__ACTIVITY__", render_activity(demo).as_str())
}
fn shell(body: String) -> String {
let control_css = include_str!("../templates/control_center.css");
format!(
r#"<!doctype html>
<html lang="en">
@@ -401,13 +433,6 @@ fn shell(body: String) -> String {
.section-heading {{ display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }}
.section-heading strong {{ color:var(--cyan); }}
.lanes {{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; align-items:start; }}
.lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }}
.lane h3 {{ margin:0 0 4px; }}
.lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }}
.work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; }}
.work-card:active {{ cursor:grabbing; }}
.work-card.is-selected {{ border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); }}
.lane.drop-ready {{ border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); }}
.work-card header {{ display:flex; justify-content:space-between; gap:10px; align-items:start; }}
.pill {{ display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }}
.impact {{ height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }}
@@ -428,6 +453,7 @@ fn shell(body: String) -> String {
code {{ color:var(--cyan); }}
@media (max-width: 920px) {{ .hero-shell,.workspace,.insight-grid {{ grid-template-columns:1fr; }} .lanes {{ grid-template-columns:1fr; }} }}
</style>
<style>{control_css}</style>
</head>
<body>{body}</body>
</html>"#
@@ -457,7 +483,7 @@ fn render_board(demo: &DemoState) -> String {
.iter()
.enumerate()
.map(|(idx, (lane_id, title, description))| IssueLane {
class: String::from("lane"),
class: ui::control_center::classes::lane,
lane_id,
move_handle: ui::control_center::handles::move_to_lane.id().id,
title,
@@ -482,9 +508,12 @@ fn render_board(demo: &DemoState) -> String {
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
IssueCard {
class: if selected {
String::from("work-card is-selected")
CssClasses::from([
ui::control_center::classes::work_card,
ui::control_center::classes::is_selected,
])
} else {
String::from("work-card")
CssClasses::from(ui::control_center::classes::work_card)
},
id: item.id,
title: item.title.clone(),
@@ -496,13 +525,11 @@ fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
}
}
fn render_activity(demo: &DemoState) -> String {
let mut out = String::from("<ol class=\"activity\">");
for item in &demo.activity {
out.push_str(&format!("<li>{}</li>", escape_html(item)));
}
out.push_str("</ol>");
out
fn render_activity(demo: &DemoState) -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ActivityFeed {
items: demo.activity.iter().cloned().collect(),
})
}
fn render_inspector(demo: &DemoState) -> String {
@@ -526,37 +553,183 @@ fn render_inspector(demo: &DemoState) -> String {
})
}
fn live_feed(tick: u64) -> String {
format!(
r#"<div class="live-row"><strong>SSE tick #{tick}</strong><br>Server streamed a typed EffectBatch into <code>slots::live_feed</code>.</div>"#
)
fn live_feed(tick: u64) -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&LiveFeed { tick })
}
fn architecture_hero() -> String {
"<div class=\"metrics\"><div class=\"metric\"><strong>1</strong><span>template source of truth</span></div><div class=\"metric\"><strong>0</strong><span>CSS selectors in handlers</span></div><div class=\"metric\"><strong>∞</strong><span>typed composition through tuples</span></div></div>".into()
fn architecture_hero() -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ArchitectureHero)
}
fn architecture_board() -> String {
"<div class=\"lanes\"><section class=\"lane\"><h3>hemplate</h3><p>Owns syntax and Surface facts.</p></section><section class=\"lane\"><h3>slhx-build</h3><p>Generates resources and lowering tables.</p></section><section class=\"lane\"><h3>runtime</h3><p>Executes compact EffectBatch ops.</p></section></div>".into()
fn architecture_board() -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ArchitectureBoard)
}
fn architecture_inspector() -> String {
"<div class=\"inspector-row\"><b>Page swap</b><br>This route was fetched as a partial and rendered through the same root.</div>".into()
fn architecture_inspector() -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ArchitectureInspector)
}
fn architecture_activity() -> String {
"<ol class=\"activity\"><li>Clicked a real anchor</li><li>Fetched HTML with X-SLHX-Partial</li><li>Preserved native fallback semantics</li></ol>".into()
fn architecture_activity() -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ArchitectureActivity)
}
fn render_template(template: &impl Hemplate) -> String {
template.render().expect("techdemo hemplate partial renders")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
fn render_html(template: &impl Hemplate) -> SafeHtml {
SafeHtml::trusted(render_template(template))
}
#[cfg(test)]
mod tests {
use super::*;
use scraper::{Html, Selector};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
// req: style/001 req: style/002 req: style/003 req: test/005
#[test]
fn generated_css_class_flows_through_hemplate_dynamic_attribute() {
let board = render_board(&DemoState::default());
assert_eq!(ui::control_center::classes::lane.as_str(), "lane");
assert_eq!(ui::control_center::classes::work_card.as_str(), "work-card");
assert_eq!(ui::control_center::classes::is_selected.as_str(), "is-selected");
let document = Html::parse_fragment(&board);
let lane = document
.select(&selector(r#"section.lane[data-lane="compiler"]"#))
.next()
.expect("compiler lane renders");
assert_eq!(lane.value().attr("class"), Some("lane"));
let selected_card = document
.select(&selector(r#"article.work-card.is-selected[data-key="2"]"#))
.next()
.expect("selected work card renders");
assert_eq!(selected_card.value().attr("class"), Some("work-card is-selected"));
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn live_feed_payload_is_rendered_by_a_hemplate_view() {
let html = live_feed(7);
let document = Html::parse_fragment(html.as_str());
let row = document
.select(&selector(".live-row"))
.next()
.expect("live feed row renders");
let text = row.text().collect::<String>();
assert!(text.contains("SSE tick #7"));
assert!(row
.select(&selector("code"))
.any(|code| code.text().collect::<String>() == "slots::live_feed"));
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn activity_payload_is_rendered_by_a_hemplate_view() {
let mut demo = DemoState::default();
demo.activity.push_back("<b>escaped activity</b>".to_owned());
let html = render_activity(&demo);
let document = Html::parse_fragment(html.as_str());
let list = document
.select(&selector("ol.activity"))
.next()
.expect("activity list renders");
let items = list.select(&selector("li")).collect::<Vec<_>>();
assert_eq!(items.len(), demo.activity.len());
assert!(items
.last()
.expect("activity item renders")
.text()
.collect::<String>()
.contains("<b>escaped activity</b>"));
assert!(list.select(&selector("b")).next().is_none());
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn architecture_activity_is_rendered_by_a_hemplate_view() {
let html = architecture_activity();
let document = Html::parse_fragment(html.as_str());
let list = document
.select(&selector("ol.activity"))
.next()
.expect("architecture activity list renders");
let items = list.select(&selector("li")).collect::<Vec<_>>();
assert_eq!(items.len(), 3);
assert_eq!(
items[1].text().collect::<String>(),
"Fetched HTML with X-SLHX-Partial"
);
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn architecture_inspector_is_rendered_by_a_hemplate_view() {
let html = architecture_inspector();
let document = Html::parse_fragment(html.as_str());
let row = document
.select(&selector(".inspector-row"))
.next()
.expect("architecture inspector row renders");
assert_eq!(
row.select(&selector("b")).next().map(|b| b.text().collect::<String>()),
Some("Page swap".to_owned())
);
assert!(row
.text()
.collect::<String>()
.contains("rendered through the same root"));
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn architecture_hero_is_rendered_by_a_hemplate_view() {
let html = architecture_hero();
let document = Html::parse_fragment(html.as_str());
let metrics = document.select(&selector(".metric")).collect::<Vec<_>>();
assert_eq!(metrics.len(), 3);
assert_eq!(
metrics[0]
.select(&selector("span"))
.next()
.map(|span| span.text().collect::<String>()),
Some("template source of truth".to_owned())
);
assert!(document
.select(&selector(".metrics .metric strong"))
.any(|strong| strong.text().collect::<String>() == ""));
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn architecture_board_is_rendered_by_a_hemplate_view() {
let html = architecture_board();
let document = Html::parse_fragment(html.as_str());
let lanes = document.select(&selector(".lanes > section.lane")).collect::<Vec<_>>();
assert_eq!(lanes.len(), 3);
assert_eq!(
lanes[0]
.select(&selector("h3"))
.next()
.map(|heading| heading.text().collect::<String>()),
Some("hemplate".to_owned())
);
assert!(lanes.iter().any(|lane| {
lane.select(&selector("p"))
.next()
.map(|paragraph| paragraph.text().collect::<String>())
.is_some_and(|text| text.contains("EffectBatch ops"))
}));
}
}
@@ -0,0 +1,7 @@
.lane { min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }
.lane h3 { margin:0 0 4px; }
.lane p { color:var(--muted); margin:0 0 12px; font-size:13px; }
.lane.drop-ready { border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); }
.work-card { border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; }
.work-card:active { cursor:grabbing; }
.work-card.is-selected { border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); }
@@ -0,0 +1,3 @@
<ol class="activity">
<li h-for="item in &self.items">{+ item +}</li>
</ol>
@@ -0,0 +1,5 @@
<ol class="activity">
<li>Clicked a real anchor</li>
<li>Fetched HTML with X-SLHX-Partial</li>
<li>Preserved native fallback semantics</li>
</ol>
@@ -0,0 +1,5 @@
<div class="lanes">
<section class="lane"><h3>hemplate</h3><p>Owns syntax and Surface facts.</p></section>
<section class="lane"><h3>slhx-build</h3><p>Generates resources and lowering tables.</p></section>
<section class="lane"><h3>runtime</h3><p>Executes compact EffectBatch ops.</p></section>
</div>
@@ -0,0 +1,5 @@
<div class="metrics">
<div class="metric"><strong>1</strong><span>template source of truth</span></div>
<div class="metric"><strong>0</strong><span>CSS selectors in handlers</span></div>
<div class="metric"><strong>∞</strong><span>typed composition through tuples</span></div>
</div>
@@ -0,0 +1 @@
<div class="inspector-row"><b>Page swap</b><br>This route was fetched as a partial and rendered through the same root.</div>
@@ -0,0 +1 @@
<div class="live-row"><strong>SSE tick #{+ self.tick +}</strong><br>Server streamed a typed EffectBatch into <code>slots::live_feed</code>.</div>
+274 -1
View File
@@ -4,7 +4,7 @@ use hemplate_core::surface::{
SurfaceDocument, SurfaceNodeKind,
};
use slhx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -49,6 +49,10 @@ impl AppBuilder {
let surface = extract_surface(&doc);
resources.add_surface(&self.template_dir, &path, &surface)?;
}
for path in collect_stylesheets(&self.template_dir)? {
let source = std::fs::read_to_string(&path)?;
resources.add_stylesheet(&self.template_dir, &path, &source)?;
}
std::fs::write(out_dir.join("slhx.generated.rs"), resources.generated_rs())?;
std::fs::write(out_dir.join("slhx.syms"), resources.syms())?;
@@ -70,6 +74,7 @@ struct Resources {
handle_forms: BTreeMap<String, String>,
forms: BTreeMap<String, FormResource>,
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
}
#[derive(Clone, Debug)]
@@ -94,6 +99,14 @@ struct GeneratedControl {
required: bool,
}
#[derive(Clone, Debug)]
struct ClassToken {
symbol: String,
ident: String,
component: String,
token: String,
}
impl Resources {
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
let component = component_ident(root, path)?;
@@ -102,6 +115,13 @@ impl Resources {
continue;
};
if let Some(class_attr) = static_attr(&node.attrs, "class") {
for token in class_tokens(&class_attr) {
let canonical = canonical_symbol(root, path, token);
self.insert_class(canonical, token.to_owned(), component.clone())?;
}
}
if let Some(name) = static_attr(&node.attrs, "data-slhx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(surface, node.scope);
@@ -159,6 +179,38 @@ impl Resources {
insert_resource(&mut self.atoms, "atom", symbol, name, component, false)
}
fn insert_class(&mut self, symbol: String, token: String, component: String) -> io::Result<()> {
let ident = class_ident(&token).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid CSS class `{token}`; expected an ASCII class token usable from Rust"),
)
})?;
let class = ClassToken { symbol, ident, component, token };
if let Some(existing) = self.classes.values().find(|existing| {
existing.ident == class.ident && existing.token != class.token
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"duplicate generated class identifier `{}` for CSS classes `{}` and `{}`",
class.ident, existing.token, class.token
),
));
}
match self.classes.get(&class.symbol) {
Some(existing) if existing.token != class.token => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("conflicting CSS class token for `{}`", existing.symbol),
)),
Some(_) => Ok(()),
None => {
self.classes.insert(class.symbol.clone(), class);
Ok(())
}
}
}
fn insert_form(
&mut self,
symbol: String,
@@ -183,6 +235,15 @@ impl Resources {
}
}
fn add_stylesheet(&mut self, root: &Path, path: &Path, source: &str) -> io::Result<()> {
let component = component_ident(root, path)?;
for token in stylesheet_class_tokens(source) {
let canonical = canonical_symbol(root, path, token);
self.insert_class(canonical, token.to_owned(), component.clone())?;
}
Ok(())
}
fn generated_rs(&self) -> String {
let mut out = String::new();
out.push_str("// @generated by slhx-build. Do not edit.\n");
@@ -264,6 +325,20 @@ impl Resources {
}
out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod classes {{\n"));
let mut emitted_classes = BTreeSet::new();
for class in self.classes.values().filter(|class| class_matches(class, component)) {
if !emitted_classes.insert(class.ident.as_str()) {
continue;
}
out.push_str(&format!(
"{inner}pub const {}: ::slhx::CssClass = ::slhx::CssClass::new({});\n",
class.ident,
rust_str(&class.token)
));
}
out.push_str(&format!("{pad}}}\n\n"));
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;
@@ -418,6 +493,7 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
.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))
.chain(self.classes.values().map(|class| &class.component))
{
if !components.contains(component) {
components.push(component.clone());
@@ -442,6 +518,9 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
for res in self.atoms.values() {
out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
}
for class in self.classes.values() {
out.push_str(&format!("class\t{}\t{}\t{}\n", class.symbol, class.ident, class.token));
}
out
}
@@ -549,6 +628,29 @@ fn collect_heml_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
Ok(())
}
fn collect_stylesheets(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if !root.exists() {
return Ok(paths);
}
collect_stylesheets_into(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_stylesheets_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_stylesheets_into(&path, paths)?;
} else if matches!(path.extension().and_then(|ext| ext.to_str()), Some("css" | "scss")) {
paths.push(path);
}
}
Ok(())
}
fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option<String> {
attrs
.iter()
@@ -563,6 +665,88 @@ fn component_matches(res: &Resource, component: Option<&str>) -> bool {
}
}
fn class_matches(class: &ClassToken, component: Option<&str>) -> bool {
match component {
Some(component) => class.component == component,
None => true,
}
}
fn class_tokens(value: &str) -> impl Iterator<Item = &str> {
value.split_ascii_whitespace().filter(|token| !token.is_empty())
}
fn stylesheet_class_tokens(source: &str) -> Vec<&str> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'.' {
i += 1;
continue;
}
let prev = i.checked_sub(1).map(|idx| bytes[idx]);
if prev.is_some_and(|ch| ch == b'-' || ch == b'_' || ch.is_ascii_alphanumeric())
&& !preceded_by_class_in_compound(bytes, i)
{
i += 1;
continue;
}
let start = i + 1;
if start >= bytes.len() || !is_class_start(bytes[start]) {
i += 1;
continue;
}
let mut end = start + 1;
while end < bytes.len() && is_class_continue(bytes[end]) {
end += 1;
}
if let Ok(token) = std::str::from_utf8(&bytes[start..end]) {
tokens.push(token);
}
i = end;
}
tokens.sort_unstable();
tokens.dedup();
tokens
}
fn preceded_by_class_in_compound(bytes: &[u8], dot: usize) -> bool {
let mut i = dot;
while let Some(prev) = i.checked_sub(1) {
let byte = bytes[prev];
if byte == b'.' {
return true;
}
if matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b',' | b'{' | b'}' | b'>' | b'+' | b'~' | b'(' | b')') {
return false;
}
i = prev;
}
false
}
fn is_class_start(byte: u8) -> bool {
byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic()
}
fn is_class_continue(byte: u8) -> bool {
is_class_start(byte) || byte.is_ascii_digit()
}
fn class_ident(token: &str) -> Option<String> {
let mut ident = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'-' => ident.push('_'),
'_' => ident.push('_'),
ch if ch.is_ascii_alphanumeric() => ident.push(ch),
_ => return None,
}
}
rust_ident(&ident)
}
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
@@ -734,6 +918,95 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn emits_checked_css_class_tokens_from_templates_and_stylesheets() {
// req: style/001, req: codegen/001
let base = test_dir("slhx-build-classes-test");
let templates = base.join("templates");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("card.heml"),
r#"<article class="card is-active" data-slhx-slot="card_body"></article>"#,
)
.unwrap();
std::fs::write(
templates.join("card.css"),
r#".card { padding: 1rem; }
.is-active:hover, .drag-handle { cursor: grab; }
.work-card.is-selected { outline: 1px solid currentColor; }
"#,
)
.unwrap();
std::fs::write(
templates.join("panel.scss"),
r#".panel-shell { &.is-open { display: block; } }"#,
)
.unwrap();
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("pub mod classes"));
assert!(generated.contains("pub const card: ::slhx::CssClass = ::slhx::CssClass::new(\"card\")"));
assert!(generated.contains("pub const is_active: ::slhx::CssClass = ::slhx::CssClass::new(\"is-active\")"));
assert!(generated.contains("pub const drag_handle: ::slhx::CssClass = ::slhx::CssClass::new(\"drag-handle\")"));
assert!(generated.contains("pub const work_card: ::slhx::CssClass = ::slhx::CssClass::new(\"work-card\")"));
assert!(generated.contains("pub const is_selected: ::slhx::CssClass = ::slhx::CssClass::new(\"is-selected\")"));
assert!(generated.contains("pub mod card"));
assert!(generated.contains("pub mod panel"));
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
assert!(syms.contains("class\t"));
assert!(syms.contains("\tdrag_handle\tdrag-handle"));
let source = format!(
r###"
#![allow(dead_code)]
mod slhx {{
#[derive(Clone, Copy)] pub struct BuildFingerprint;
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Handle<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
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 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 }} }}
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> }} }}
}}
{generated}
fn main() {{
assert_eq!(classes::drag_handle.as_str(), "drag-handle");
assert_eq!(card::classes::is_active.as_str(), "is-active");
assert_eq!(panel::classes::panel_shell.as_str(), "panel-shell");
}}
"###,
);
let source_path = base.join("classes.rs");
let bin_path = base.join("classes-bin");
std::fs::write(&source_path, source).unwrap();
let status = std::process::Command::new("rustc")
.arg(&source_path)
.arg("-o")
.arg(&bin_path)
.status()
.unwrap();
assert!(status.success());
let status = std::process::Command::new(&bin_path).status().unwrap();
assert!(status.success());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn rejects_slhx_resources_inside_unkeyed_for() {
let base = test_dir("slhx-build-unkeyed-for-test");
+79
View File
@@ -143,6 +143,85 @@ impl SafeHtml {
}
}
/// A generated, checked CSS class token.
///
/// Plain CSS/SCSS owns appearance; slhx only gives Rust a typed reference to
/// class names discovered from build inputs. req: style/001
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct CssClass {
name: &'static str,
}
impl CssClass {
pub const fn new(name: &'static str) -> Self {
Self { name }
}
pub const fn as_str(self) -> &'static str {
self.name
}
}
impl AsRef<str> for CssClass {
fn as_ref(&self) -> &str {
self.name
}
}
impl core::fmt::Display for CssClass {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.name)
}
}
/// A small displayable list of generated CSS class tokens for hemplate `+class`.
/// req: style/003
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct CssClasses {
names: String,
}
impl CssClasses {
pub fn new(classes: impl IntoIterator<Item = CssClass>) -> Self {
let mut names = String::new();
for class in classes {
if !names.is_empty() {
names.push(' ');
}
names.push_str(class.as_str());
}
Self { names }
}
pub fn as_str(&self) -> &str {
&self.names
}
}
impl From<CssClass> for CssClasses {
fn from(class: CssClass) -> Self {
Self::new([class])
}
}
impl<const N: usize> From<[CssClass; N]> for CssClasses {
fn from(classes: [CssClass; N]) -> Self {
Self::new(classes)
}
}
impl AsRef<str> for CssClasses {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl core::fmt::Display for CssClasses {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.names)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct FormContract {
pub fields: &'static [FormField],
+17 -1
View File
@@ -1,4 +1,8 @@
use slhx_core::{event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, NavigateMode, Payload, ResourceKind, SafeHtml, ScopeKey, Slot};
use slhx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
CssClass, CssClasses, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, NavigateMode, Payload,
ResourceKind, SafeHtml, ScopeKey, Slot,
};
#[test]
fn effect_batch_wire_round_trips() {
@@ -88,6 +92,18 @@ fn slot_html_requires_explicit_safe_html() {
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
}
#[test]
fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
// req: style/003
const CARD: CssClass = CssClass::new("work-card");
const SELECTED: CssClass = CssClass::new("is-selected");
let classes = CssClasses::from([CARD, SELECTED]);
assert_eq!(classes.as_str(), "work-card is-selected");
assert_eq!(classes.to_string(), "work-card is-selected");
}
#[test]
fn atom_state_bootstrap_is_postcard_round_trippable() {
let state = AtomState {
+2 -2
View File
@@ -8,8 +8,8 @@ pub use slhx_derive::{app, component, handler, surface};
pub mod prelude {
pub use slhx_core::{
navigate, push, redirect, replace, Atom, BuildFingerprint, Effect, Form, Handle,
IntoEffect, KeyedSlot, Slot,
navigate, push, redirect, replace, Atom, BuildFingerprint, CssClass, CssClasses, Effect,
Form, Handle, IntoEffect, KeyedSlot, Slot,
};
pub use slhx_derive::{app, component, handler, surface};
}