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:
+274
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user