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:
@@ -599,6 +599,9 @@ what a valid business email is.
|
|||||||
### req: test/004
|
### 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.
|
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
|
## 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
|
## convention
|
||||||
|
|
||||||
### req: convention/001
|
### req: convention/001
|
||||||
|
|||||||
+223
-50
@@ -4,8 +4,11 @@ use axum::routing::get;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{IntoEffect, SafeHtml};
|
use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml};
|
||||||
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
|
use slhx_axum::{
|
||||||
|
runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
|
||||||
|
PageRequest,
|
||||||
|
};
|
||||||
use slhx_techdemo::ui;
|
use slhx_techdemo::ui;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
@@ -97,7 +100,7 @@ struct Shared {
|
|||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct IssueLane {
|
struct IssueLane {
|
||||||
class: String,
|
class: CssClass,
|
||||||
lane_id: &'static str,
|
lane_id: &'static str,
|
||||||
move_handle: u32,
|
move_handle: u32,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
@@ -108,7 +111,7 @@ struct IssueLane {
|
|||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct IssueCard {
|
struct IssueCard {
|
||||||
class: String,
|
class: CssClasses,
|
||||||
id: u64,
|
id: u64,
|
||||||
title: String,
|
title: String,
|
||||||
stage: &'static str,
|
stage: &'static str,
|
||||||
@@ -138,6 +141,34 @@ struct InspectorSelected {
|
|||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct InspectorEmpty;
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) });
|
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
|
// req: page_swap/001 req: page_swap/002 req: examples/001
|
||||||
async fn architecture(request: PageRequest) -> impl IntoResponse {
|
async fn architecture(request: PageRequest) -> impl IntoResponse {
|
||||||
let body = ui::control_center::lower_html(include_str!("../templates/control_center.heml"))
|
let body = ui::control_center::lower_html(include_str!("../templates/control_center.heml"))
|
||||||
.replace("__HERO__", &architecture_hero())
|
.replace("__HERO__", architecture_hero().as_str())
|
||||||
.replace("__BOARD__", &architecture_board())
|
.replace("__BOARD__", architecture_board().as_str())
|
||||||
.replace("__INSPECTOR__", &architecture_inspector())
|
.replace("__INSPECTOR__", architecture_inspector().as_str())
|
||||||
.replace("__ACTIVITY__", &architecture_activity());
|
.replace("__ACTIVITY__", architecture_activity().as_str());
|
||||||
request
|
request
|
||||||
.page(body, shell)
|
.page(body, shell)
|
||||||
.title("slhx Architecture")
|
.title("slhx Architecture")
|
||||||
@@ -193,13 +224,13 @@ async fn interact(
|
|||||||
// req: push/001 req: push/003 req: examples/001
|
// req: push/001 req: push/003 req: examples/001
|
||||||
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
||||||
if params.contains_key("once") {
|
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());
|
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let batches = stream::unfold(1_u64, |tick| async move {
|
let batches = stream::unfold(1_u64, |tick| async move {
|
||||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
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))
|
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1))
|
||||||
})
|
})
|
||||||
.boxed();
|
.boxed();
|
||||||
@@ -308,8 +339,8 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
|
|||||||
let mut demo = shared.demo.lock().unwrap();
|
let mut demo = shared.demo.lock().unwrap();
|
||||||
demo.log("Simulated push event produced the same EffectBatch shape");
|
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::live_feed.html(live_feed(demo.activity.len() as u64)),
|
||||||
ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(&demo))),
|
ui::control_center::slots::activity.html(render_activity(&demo)),
|
||||||
ui::control_center::slots::notice.text("Push simulated · no client app code"),
|
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::hero_metrics.html(SafeHtml::trusted(render_hero(demo))),
|
||||||
ui::control_center::slots::board.html(SafeHtml::trusted(render_board(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::inspector.html(SafeHtml::trusted(render_inspector(demo))),
|
||||||
ui::control_center::slots::notice.text(notice),
|
ui::control_center::slots::notice.text(notice),
|
||||||
ui::control_center::forms::launch_work.clear("title"),
|
ui::control_center::forms::launch_work.clear("title"),
|
||||||
@@ -354,10 +385,11 @@ fn page_html(demo: &DemoState) -> String {
|
|||||||
.replace("__HERO__", &render_hero(demo))
|
.replace("__HERO__", &render_hero(demo))
|
||||||
.replace("__BOARD__", &render_board(demo))
|
.replace("__BOARD__", &render_board(demo))
|
||||||
.replace("__INSPECTOR__", &render_inspector(demo))
|
.replace("__INSPECTOR__", &render_inspector(demo))
|
||||||
.replace("__ACTIVITY__", &render_activity(demo))
|
.replace("__ACTIVITY__", render_activity(demo).as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: String) -> String {
|
fn shell(body: String) -> String {
|
||||||
|
let control_css = include_str!("../templates/control_center.css");
|
||||||
format!(
|
format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="en">
|
<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 {{ display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }}
|
||||||
.section-heading strong {{ color:var(--cyan); }}
|
.section-heading strong {{ color:var(--cyan); }}
|
||||||
.lanes {{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; align-items:start; }}
|
.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; }}
|
.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); }}
|
.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; }}
|
.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); }}
|
code {{ color:var(--cyan); }}
|
||||||
@media (max-width: 920px) {{ .hero-shell,.workspace,.insight-grid {{ grid-template-columns:1fr; }} .lanes {{ grid-template-columns:1fr; }} }}
|
@media (max-width: 920px) {{ .hero-shell,.workspace,.insight-grid {{ grid-template-columns:1fr; }} .lanes {{ grid-template-columns:1fr; }} }}
|
||||||
</style>
|
</style>
|
||||||
|
<style>{control_css}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>{body}</body>
|
<body>{body}</body>
|
||||||
</html>"#
|
</html>"#
|
||||||
@@ -457,7 +483,7 @@ fn render_board(demo: &DemoState) -> String {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, (lane_id, title, description))| IssueLane {
|
.map(|(idx, (lane_id, title, description))| IssueLane {
|
||||||
class: String::from("lane"),
|
class: ui::control_center::classes::lane,
|
||||||
lane_id,
|
lane_id,
|
||||||
move_handle: ui::control_center::handles::move_to_lane.id().id,
|
move_handle: ui::control_center::handles::move_to_lane.id().id,
|
||||||
title,
|
title,
|
||||||
@@ -482,9 +508,12 @@ fn render_board(demo: &DemoState) -> String {
|
|||||||
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
||||||
IssueCard {
|
IssueCard {
|
||||||
class: if selected {
|
class: if selected {
|
||||||
String::from("work-card is-selected")
|
CssClasses::from([
|
||||||
|
ui::control_center::classes::work_card,
|
||||||
|
ui::control_center::classes::is_selected,
|
||||||
|
])
|
||||||
} else {
|
} else {
|
||||||
String::from("work-card")
|
CssClasses::from(ui::control_center::classes::work_card)
|
||||||
},
|
},
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title.clone(),
|
title: item.title.clone(),
|
||||||
@@ -496,13 +525,11 @@ fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_activity(demo: &DemoState) -> String {
|
fn render_activity(demo: &DemoState) -> SafeHtml {
|
||||||
let mut out = String::from("<ol class=\"activity\">");
|
// req: html_safety/002 req: view/001
|
||||||
for item in &demo.activity {
|
render_html(&ActivityFeed {
|
||||||
out.push_str(&format!("<li>{}</li>", escape_html(item)));
|
items: demo.activity.iter().cloned().collect(),
|
||||||
}
|
})
|
||||||
out.push_str("</ol>");
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_inspector(demo: &DemoState) -> String {
|
fn render_inspector(demo: &DemoState) -> String {
|
||||||
@@ -526,37 +553,183 @@ fn render_inspector(demo: &DemoState) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_feed(tick: u64) -> String {
|
fn live_feed(tick: u64) -> SafeHtml {
|
||||||
format!(
|
// req: html_safety/002 req: view/001
|
||||||
r#"<div class="live-row"><strong>SSE tick #{tick}</strong><br>Server streamed a typed EffectBatch into <code>slots::live_feed</code>.</div>"#
|
render_html(&LiveFeed { tick })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_hero() -> String {
|
fn architecture_hero() -> SafeHtml {
|
||||||
"<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()
|
// req: html_safety/002 req: view/001
|
||||||
|
render_html(&ArchitectureHero)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_board() -> String {
|
fn architecture_board() -> SafeHtml {
|
||||||
"<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()
|
// req: html_safety/002 req: view/001
|
||||||
|
render_html(&ArchitectureBoard)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_inspector() -> String {
|
fn architecture_inspector() -> SafeHtml {
|
||||||
"<div class=\"inspector-row\"><b>Page swap</b><br>This route was fetched as a partial and rendered through the same root.</div>".into()
|
// req: html_safety/002 req: view/001
|
||||||
|
render_html(&ArchitectureInspector)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_activity() -> String {
|
fn architecture_activity() -> SafeHtml {
|
||||||
"<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()
|
// req: html_safety/002 req: view/001
|
||||||
|
render_html(&ArchitectureActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_template(template: &impl Hemplate) -> String {
|
fn render_template(template: &impl Hemplate) -> String {
|
||||||
template.render().expect("techdemo hemplate partial renders")
|
template.render().expect("techdemo hemplate partial renders")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html(value: &str) -> String {
|
fn render_html(template: &impl Hemplate) -> SafeHtml {
|
||||||
value
|
SafeHtml::trusted(render_template(template))
|
||||||
.replace('&', "&")
|
}
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
#[cfg(test)]
|
||||||
.replace('"', """)
|
mod tests {
|
||||||
.replace('\'', "'")
|
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
@@ -4,7 +4,7 @@ use hemplate_core::surface::{
|
|||||||
SurfaceDocument, SurfaceNodeKind,
|
SurfaceDocument, SurfaceNodeKind,
|
||||||
};
|
};
|
||||||
use slhx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
|
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::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -49,6 +49,10 @@ impl AppBuilder {
|
|||||||
let surface = extract_surface(&doc);
|
let surface = extract_surface(&doc);
|
||||||
resources.add_surface(&self.template_dir, &path, &surface)?;
|
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.generated.rs"), resources.generated_rs())?;
|
||||||
std::fs::write(out_dir.join("slhx.syms"), resources.syms())?;
|
std::fs::write(out_dir.join("slhx.syms"), resources.syms())?;
|
||||||
@@ -70,6 +74,7 @@ struct Resources {
|
|||||||
handle_forms: BTreeMap<String, String>,
|
handle_forms: BTreeMap<String, String>,
|
||||||
forms: BTreeMap<String, FormResource>,
|
forms: BTreeMap<String, FormResource>,
|
||||||
atoms: BTreeMap<String, Resource>,
|
atoms: BTreeMap<String, Resource>,
|
||||||
|
classes: BTreeMap<String, ClassToken>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -94,6 +99,14 @@ struct GeneratedControl {
|
|||||||
required: bool,
|
required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ClassToken {
|
||||||
|
symbol: String,
|
||||||
|
ident: String,
|
||||||
|
component: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Resources {
|
impl Resources {
|
||||||
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
||||||
let component = component_ident(root, path)?;
|
let component = component_ident(root, path)?;
|
||||||
@@ -102,6 +115,13 @@ impl Resources {
|
|||||||
continue;
|
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") {
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-slot") {
|
||||||
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
||||||
let keyed = is_inside_keyed_for(surface, node.scope);
|
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)
|
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(
|
fn insert_form(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: String,
|
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 {
|
fn generated_rs(&self) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str("// @generated by slhx-build. Do not edit.\n");
|
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}}}\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"));
|
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)) {
|
for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) {
|
||||||
let res = &form.resource;
|
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.handles.values().map(|res| &res.component))
|
||||||
.chain(self.atoms.values().map(|res| &res.component))
|
.chain(self.atoms.values().map(|res| &res.component))
|
||||||
.chain(self.forms.values().map(|form| &form.resource.component))
|
.chain(self.forms.values().map(|form| &form.resource.component))
|
||||||
|
.chain(self.classes.values().map(|class| &class.component))
|
||||||
{
|
{
|
||||||
if !components.contains(component) {
|
if !components.contains(component) {
|
||||||
components.push(component.clone());
|
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() {
|
for res in self.atoms.values() {
|
||||||
out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +628,29 @@ fn collect_heml_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
|
|||||||
Ok(())
|
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> {
|
fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option<String> {
|
||||||
attrs
|
attrs
|
||||||
.iter()
|
.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 {
|
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
|
||||||
loop {
|
loop {
|
||||||
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
||||||
@@ -734,6 +918,95 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn rejects_slhx_resources_inside_unkeyed_for() {
|
fn rejects_slhx_resources_inside_unkeyed_for() {
|
||||||
let base = test_dir("slhx-build-unkeyed-for-test");
|
let base = test_dir("slhx-build-unkeyed-for-test");
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
pub struct FormContract {
|
pub struct FormContract {
|
||||||
pub fields: &'static [FormField],
|
pub fields: &'static [FormField],
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn effect_batch_wire_round_trips() {
|
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>")));
|
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]
|
#[test]
|
||||||
fn atom_state_bootstrap_is_postcard_round_trippable() {
|
fn atom_state_bootstrap_is_postcard_round_trippable() {
|
||||||
let state = AtomState {
|
let state = AtomState {
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ pub use slhx_derive::{app, component, handler, surface};
|
|||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use slhx_core::{
|
pub use slhx_core::{
|
||||||
navigate, push, redirect, replace, Atom, BuildFingerprint, Effect, Form, Handle,
|
navigate, push, redirect, replace, Atom, BuildFingerprint, CssClass, CssClasses, Effect,
|
||||||
IntoEffect, KeyedSlot, Slot,
|
Form, Handle, IntoEffect, KeyedSlot, Slot,
|
||||||
};
|
};
|
||||||
pub use slhx_derive::{app, component, handler, surface};
|
pub use slhx_derive::{app, component, handler, surface};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user