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
+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");