From fee976206989cff2cc93aad9f1df7fd19e4516aa Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 20:48:31 +0200 Subject: [PATCH] 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 --- REQUIREMENTS.md | 16 + examples/techdemo/src/main.rs | 273 +++++++++++++---- .../techdemo/templates/control_center.css | 7 + .../templates/partials/activity_feed.heml | 3 + .../partials/architecture_activity.heml | 5 + .../partials/architecture_board.heml | 5 + .../templates/partials/architecture_hero.heml | 5 + .../partials/architecture_inspector.heml | 1 + .../templates/partials/live_feed.heml | 1 + slhx-build/src/lib.rs | 275 +++++++++++++++++- slhx-core/src/lib.rs | 79 +++++ slhx-core/tests/effect_batch.rs | 18 +- slhx/src/lib.rs | 4 +- 13 files changed, 638 insertions(+), 54 deletions(-) create mode 100644 examples/techdemo/templates/control_center.css create mode 100644 examples/techdemo/templates/partials/activity_feed.heml create mode 100644 examples/techdemo/templates/partials/architecture_activity.heml create mode 100644 examples/techdemo/templates/partials/architecture_board.heml create mode 100644 examples/techdemo/templates/partials/architecture_hero.heml create mode 100644 examples/techdemo/templates/partials/architecture_inspector.heml create mode 100644 examples/techdemo/templates/partials/live_feed.heml diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5c43a02..1805d78 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 5b6df0b..9bba662 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -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, +} + +#[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>, 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>) -> 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) -> 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#" @@ -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; }} }} + {body} "# @@ -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("
    "); - for item in &demo.activity { - out.push_str(&format!("
  1. {}
  2. ", escape_html(item))); - } - out.push_str("
"); - 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#"
SSE tick #{tick}
Server streamed a typed EffectBatch into slots::live_feed.
"# - ) +fn live_feed(tick: u64) -> SafeHtml { + // req: html_safety/002 req: view/001 + render_html(&LiveFeed { tick }) } -fn architecture_hero() -> String { - "
1template source of truth
0CSS selectors in handlers
โˆžtyped composition through tuples
".into() +fn architecture_hero() -> SafeHtml { + // req: html_safety/002 req: view/001 + render_html(&ArchitectureHero) } -fn architecture_board() -> String { - "

hemplate

Owns syntax and Surface facts.

slhx-build

Generates resources and lowering tables.

runtime

Executes compact EffectBatch ops.

".into() +fn architecture_board() -> SafeHtml { + // req: html_safety/002 req: view/001 + render_html(&ArchitectureBoard) } -fn architecture_inspector() -> String { - "
Page swap
This route was fetched as a partial and rendered through the same root.
".into() +fn architecture_inspector() -> SafeHtml { + // req: html_safety/002 req: view/001 + render_html(&ArchitectureInspector) } -fn architecture_activity() -> String { - "
  1. Clicked a real anchor
  2. Fetched HTML with X-SLHX-Partial
  3. Preserved native fallback semantics
".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('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") +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::(); + assert!(text.contains("SSE tick #7")); + assert!(row + .select(&selector("code")) + .any(|code| code.text().collect::() == "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("escaped activity".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::>(); + assert_eq!(items.len(), demo.activity.len()); + assert!(items + .last() + .expect("activity item renders") + .text() + .collect::() + .contains("escaped activity")); + 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::>(); + assert_eq!(items.len(), 3); + assert_eq!( + items[1].text().collect::(), + "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::()), + Some("Page swap".to_owned()) + ); + assert!(row + .text() + .collect::() + .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::>(); + assert_eq!(metrics.len(), 3); + assert_eq!( + metrics[0] + .select(&selector("span")) + .next() + .map(|span| span.text().collect::()), + Some("template source of truth".to_owned()) + ); + assert!(document + .select(&selector(".metrics .metric strong")) + .any(|strong| strong.text().collect::() == "โˆž")); + } + + // 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::>(); + assert_eq!(lanes.len(), 3); + assert_eq!( + lanes[0] + .select(&selector("h3")) + .next() + .map(|heading| heading.text().collect::()), + Some("hemplate".to_owned()) + ); + assert!(lanes.iter().any(|lane| { + lane.select(&selector("p")) + .next() + .map(|paragraph| paragraph.text().collect::()) + .is_some_and(|text| text.contains("EffectBatch ops")) + })); + } } diff --git a/examples/techdemo/templates/control_center.css b/examples/techdemo/templates/control_center.css new file mode 100644 index 0000000..27dc052 --- /dev/null +++ b/examples/techdemo/templates/control_center.css @@ -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); } diff --git a/examples/techdemo/templates/partials/activity_feed.heml b/examples/techdemo/templates/partials/activity_feed.heml new file mode 100644 index 0000000..5f72f9a --- /dev/null +++ b/examples/techdemo/templates/partials/activity_feed.heml @@ -0,0 +1,3 @@ +
    +
  1. {+ item +}
  2. +
diff --git a/examples/techdemo/templates/partials/architecture_activity.heml b/examples/techdemo/templates/partials/architecture_activity.heml new file mode 100644 index 0000000..5e2b1e3 --- /dev/null +++ b/examples/techdemo/templates/partials/architecture_activity.heml @@ -0,0 +1,5 @@ +
    +
  1. Clicked a real anchor
  2. +
  3. Fetched HTML with X-SLHX-Partial
  4. +
  5. Preserved native fallback semantics
  6. +
diff --git a/examples/techdemo/templates/partials/architecture_board.heml b/examples/techdemo/templates/partials/architecture_board.heml new file mode 100644 index 0000000..fb23207 --- /dev/null +++ b/examples/techdemo/templates/partials/architecture_board.heml @@ -0,0 +1,5 @@ +
+

hemplate

Owns syntax and Surface facts.

+

slhx-build

Generates resources and lowering tables.

+

runtime

Executes compact EffectBatch ops.

+
diff --git a/examples/techdemo/templates/partials/architecture_hero.heml b/examples/techdemo/templates/partials/architecture_hero.heml new file mode 100644 index 0000000..5139e37 --- /dev/null +++ b/examples/techdemo/templates/partials/architecture_hero.heml @@ -0,0 +1,5 @@ +
+
1template source of truth
+
0CSS selectors in handlers
+
โˆžtyped composition through tuples
+
diff --git a/examples/techdemo/templates/partials/architecture_inspector.heml b/examples/techdemo/templates/partials/architecture_inspector.heml new file mode 100644 index 0000000..d587e74 --- /dev/null +++ b/examples/techdemo/templates/partials/architecture_inspector.heml @@ -0,0 +1 @@ +
Page swap
This route was fetched as a partial and rendered through the same root.
diff --git a/examples/techdemo/templates/partials/live_feed.heml b/examples/techdemo/templates/partials/live_feed.heml new file mode 100644 index 0000000..df16eda --- /dev/null +++ b/examples/techdemo/templates/partials/live_feed.heml @@ -0,0 +1 @@ +
SSE tick #{+ self.tick +}
Server streamed a typed EffectBatch into slots::live_feed.
diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 019d08c..1a4adda 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -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, forms: BTreeMap, atoms: BTreeMap, + classes: BTreeMap, } #[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) -> io::Result<()> { Ok(()) } +fn collect_stylesheets(root: &Path) -> io::Result> { + 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) -> 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 { 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 { + 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 { + 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#"
"#, + ) + .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(::std::marker::PhantomData); + impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); + impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); + impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); + impl Atom {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + #[derive(Clone, Copy)] pub struct Form(::std::marker::PhantomData); + impl Form {{ 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"); diff --git a/slhx-core/src/lib.rs b/slhx-core/src/lib.rs index dd34de4..854dcd5 100644 --- a/slhx-core/src/lib.rs +++ b/slhx-core/src/lib.rs @@ -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 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) -> 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 for CssClasses { + fn from(class: CssClass) -> Self { + Self::new([class]) + } +} + +impl From<[CssClass; N]> for CssClasses { + fn from(classes: [CssClass; N]) -> Self { + Self::new(classes) + } +} + +impl AsRef 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], diff --git a/slhx-core/tests/effect_batch.rs b/slhx-core/tests/effect_batch.rs index 342dae8..14e3765 100644 --- a/slhx-core/tests/effect_batch.rs +++ b/slhx-core/tests/effect_batch.rs @@ -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("ok"))); } +#[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 { diff --git a/slhx/src/lib.rs b/slhx/src/lib.rs index 17277e5..1fa897b 100644 --- a/slhx/src/lib.rs +++ b/slhx/src/lib.rs @@ -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}; }