Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ The protocol defines the serializable structure representing UI templates. At ru
pub struct WebUIProtocol {
/// Map of fragment identifiers to their associated fragment lists.
pub fragments: HashMap<String, FragmentList>,
/// Sorted, deduplicated CSS custom property names used across all
/// components and entry page styles (without `--` prefix).
pub tokens: Vec<String>,
}

/// A list of fragments (needed because protobuf maps cannot have repeated values directly).
Expand Down Expand Up @@ -369,6 +372,8 @@ pub struct Component {
pub name: String,
pub html_content: String,
pub css_content: Option<String>,
/// Sorted, deduplicated CSS token names extracted from css_content.
pub css_tokens: Vec<String>,
}
```

Expand Down Expand Up @@ -587,6 +592,7 @@ impl CssParser {
pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords, ParserError>
pub fn process_css(&mut self, css_content: &str, fragments: &mut WebUIFragmentRecords) -> Result<(), ParserError>
pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String, ParserError>
pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>, ParserError>
}
```

Expand All @@ -597,6 +603,47 @@ impl CssParser {
- Handle nested variable references
- Process inline and external CSS

### CSS Token Hoisting

CSS Token Hoisting extracts the set of CSS custom properties (tokens) that are **used** across all components and entry page styles at build time. The sorted, deduplicated list is included in the protocol's `tokens` field, enabling host runtimes to resolve only the design tokens the application actually needs.

#### Token Extraction (`CssParser::extract_tokens`)

The `extract_tokens` method uses tree-sitter-css to iteratively walk the CSS AST and extract custom property **usages** from `var()` calls, while **excluding** locally-defined custom properties.

**Extracted (hoisted):**
- `var(--colorPrimary)` → token `"colorPrimary"`
- `var(--a, var(--b, var(--c)))` → tokens `"a"`, `"b"`, `"c"` (nested fallbacks)
- `var(--size, 16px)` → token `"size"` (literal fallbacks ignored)

**Excluded (not hoisted):**
- `--bar: 12px` — local custom property definitions
- `var(--bar)` when `--bar` is defined in the same CSS file

The iterative walker visits each `call_expression` node independently, so nested `var()` fallbacks (which are separate `call_expression` nodes in the tree-sitter AST) are naturally handled.

#### Token Collection During Parsing

The `HtmlParser` maintains a `token_store: HashSet<String>` that accumulates tokens from two sources:

1. **Component CSS** — when a component is first encountered during parsing, its pre-extracted `css_tokens` (stored in the `Component` struct at registration time) are merged into the token store.
2. **Inline `<style>` tags** — when the parser processes a `style_element` node, it calls `extract_tokens` on the CSS content and merges the result.

After parsing completes, `HtmlParser::take_tokens()` returns the sorted, deduplicated token list for inclusion in the protocol.

#### Comment-Based Signal Bindings

HTML comments containing handlebars expressions are parsed as signal fragments:

```html
<!--{{tokens}}--> → Signal { value: "tokens", raw: false }
<!--{{{tokens}}}--> → Signal { value: "tokens", raw: true }
<!--{{tokens.light}}--> → Signal { value: "tokens.light", raw: false }
<!-- regular comment --> → Raw (preserved as-is)
```

This mechanism is general-purpose (not limited to `tokens`) and enables comment-based placeholders for runtime value injection in HTML files. The existing handlebars parser is reused for expression parsing within comment delimiters.

### Error Handling
```rust
#[derive(Debug, Error)]
Expand Down
62 changes: 62 additions & 0 deletions crates/webui-cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ fn run(args: &BuildArgs) -> Result<()> {
}
));

if build_output.token_count > 0 {
output::success(&format!(
"Discovered {} CSS token{}",
console::style(build_output.token_count).bold(),
if build_output.token_count == 1 {
""
} else {
"s"
}
));
}

// Write protocol as optimized protobuf binary
let bytes = build_output
.protocol
Expand Down Expand Up @@ -509,4 +521,54 @@ mod tests {

assert!(out_dir.path().join("protocol.bin").exists());
}

#[test]
fn test_build_protocol_includes_tokens_from_components() {
let app_dir = create_app_dir(&[
("index.html", "<my-btn></my-btn>"),
("my-btn.html", "<button><slot></slot></button>"),
(
"my-btn.css",
".btn { color: var(--text-color); padding: var(--spacing-m); }",
),
]);
let out_dir = TempDir::new().unwrap();

build(app_dir.path(), out_dir.path(), "index.html").unwrap();

let bytes = fs::read(out_dir.path().join("protocol.bin")).unwrap();
let protocol = WebUIProtocol::from_protobuf(&bytes).unwrap();

assert_eq!(protocol.tokens, vec!["spacing-m", "text-color"]);
}

#[test]
fn test_build_protocol_excludes_entry_defined_tokens() {
let html = r#"<style>
:root { --text-color: #333; --spacing-m: 12px; }
body { color: var(--text-color); }
</style>
<my-btn></my-btn>"#;
let app_dir = create_app_dir(&[
("index.html", html),
("my-btn.html", "<button><slot></slot></button>"),
(
"my-btn.css",
".btn { color: var(--text-color); margin: var(--spacing-m); }",
),
]);
let out_dir = TempDir::new().unwrap();

build(app_dir.path(), out_dir.path(), "index.html").unwrap();

let bytes = fs::read(out_dir.path().join("protocol.bin")).unwrap();
let protocol = WebUIProtocol::from_protobuf(&bytes).unwrap();

// Both tokens are defined in entry :root — should not be in protocol
assert!(
protocol.tokens.is_empty(),
"Entry-defined tokens should be excluded: {:?}",
protocol.tokens
);
}
}
11 changes: 8 additions & 3 deletions crates/webui-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub struct BuildOutput {
pub fragment_count: usize,
/// Number of registered components
pub component_count: usize,
/// Number of unique CSS tokens discovered
pub token_count: usize,
}

/// Build the protocol from an app directory.
Expand Down Expand Up @@ -122,6 +124,10 @@ pub fn build_protocol(app_dir: &Path, args: &AppArgs) -> Result<BuildOutput> {
})
.collect();

// Collect CSS tokens before consuming the parser
let tokens = parser.take_tokens();
let token_count = tokens.len();

// Build protocol (consumes parser)
let fragment_records = parser.into_fragment_records();
let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
Expand All @@ -133,14 +139,13 @@ pub fn build_protocol(app_dir: &Path, args: &AppArgs) -> Result<BuildOutput> {
.map(|(tag, css)| (format!("{tag}.css"), css))
.collect();

let protocol = WebUIProtocol {
fragments: fragment_records,
};
let protocol = WebUIProtocol::with_tokens(fragment_records, tokens);

Ok(BuildOutput {
protocol,
css_files,
fragment_count,
component_count,
token_count,
})
}
158 changes: 79 additions & 79 deletions crates/webui-cli/src/commands/inspect.rs
Original file line number Diff line number Diff line change
@@ -1,79 +1,79 @@
use anyhow::{Context, Result};
use clap::Args;
use expand_tilde::expand_tilde;
use std::path::PathBuf;
use webui_protocol::WebUIProtocol;

#[derive(Args)]
pub struct InspectArgs {
/// Path to a protocol.bin file
pub file: PathBuf,
}

pub fn execute(args: &InspectArgs) -> Result<()> {
let input_file = expand_tilde(&args.file)
.with_context(|| format!("Failed to expand input path: {}", args.file.display()))?
.into_owned();

let protocol = WebUIProtocol::from_protobuf_file(&input_file)
.with_context(|| format!("Failed to read {}", args.file.display()))?;

let json = protocol
.to_json_pretty()
.context("Failed to serialize to JSON")?;

println!("{json}");
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol};

#[test]
fn test_inspect_outputs_valid_json() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello"),
WebUIFragment::signal("name", false),
],
},
);
let protocol = WebUIProtocol { fragments };

let dir = TempDir::new().unwrap();
let path = dir.path().join("protocol.bin");
protocol.to_protobuf_file(&path).unwrap();

let loaded = WebUIProtocol::from_protobuf_file(&path).unwrap();
let json = loaded.to_json_pretty().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("fragments").is_some());
assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
}

#[test]
fn test_inspect_missing_file() {
let result = execute(&InspectArgs {
file: PathBuf::from("/nonexistent/protocol.bin"),
});
assert!(result.is_err());
}

#[test]
fn test_inspect_invalid_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.bin");
fs::write(&path, b"not a protobuf").unwrap();

let result = execute(&InspectArgs { file: path });
assert!(result.is_err());
}
}
use anyhow::{Context, Result};
use clap::Args;
use expand_tilde::expand_tilde;
use std::path::PathBuf;
use webui_protocol::WebUIProtocol;
#[derive(Args)]
pub struct InspectArgs {
/// Path to a protocol.bin file
pub file: PathBuf,
}
pub fn execute(args: &InspectArgs) -> Result<()> {
let input_file = expand_tilde(&args.file)
.with_context(|| format!("Failed to expand input path: {}", args.file.display()))?
.into_owned();
let protocol = WebUIProtocol::from_protobuf_file(&input_file)
.with_context(|| format!("Failed to read {}", args.file.display()))?;
let json = protocol
.to_json_pretty()
.context("Failed to serialize to JSON")?;
println!("{json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol};
#[test]
fn test_inspect_outputs_valid_json() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello"),
WebUIFragment::signal("name", false),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let dir = TempDir::new().unwrap();
let path = dir.path().join("protocol.bin");
protocol.to_protobuf_file(&path).unwrap();
let loaded = WebUIProtocol::from_protobuf_file(&path).unwrap();
let json = loaded.to_json_pretty().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("fragments").is_some());
assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
}
#[test]
fn test_inspect_missing_file() {
let result = execute(&InspectArgs {
file: PathBuf::from("/nonexistent/protocol.bin"),
});
assert!(result.is_err());
}
#[test]
fn test_inspect_invalid_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.bin");
fs::write(&path, b"not a protobuf").unwrap();
let result = execute(&InspectArgs { file: path });
assert!(result.is_err());
}
}
Loading
Loading