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
24 changes: 22 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,27 @@ pub struct HtmlParser {
css_parser: CssParser,
condition_parser: ConditionParser,
handlebars_parser: HandlebarsParser,
css_strategy: CssStrategy,
// Other fields...
}
```

#### CSS Strategy
```rust
/// Strategy for how component CSS is delivered in rendered output.
pub enum CssStrategy {
/// Emit `<link rel="stylesheet" href="./component.css">` tags (default).
External,
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
Inline,
}
```

- **External** (default): Emits `<link>` tags referencing external `.css` files. Used by the CLI for production builds where CSS files are served separately.
- **Inline**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.

Set via `parser.set_css_strategy(CssStrategy::Inline)`.

#### Primary Method
```rust
pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<(), ParserError>
Expand Down Expand Up @@ -462,7 +480,8 @@ webui/
│ ├── webui-parser/ # HTML/CSS/template parser
│ ├── webui-protocol/ # Protocol definition
│ ├── webui-state/ # State management
│ └── webui-test-utils/ # Testing utilities
│ ├── webui-test-utils/ # Testing utilities
│ └── webui-wasm/ # WebAssembly bindings
├── examples/ # Example applications
├── docs/ # Documentation
├── tests/ # Integration tests
Expand All @@ -489,13 +508,14 @@ The `webui` CLI provides the developer-facing build toolchain for WebUI applicat
Builds a WebUI application from an app folder into the protocol format.

```bash
webui build [APP] --out <OUT> [--entry <FILE>]
webui build [APP] --out <OUT> [--entry <FILE>] [--css <MODE>]
```

**Arguments:**
- `APP` — Path to the app folder (defaults to current directory `.`)
- `--out <OUT>` — Output folder for protocol and assets (required)
- `--entry <FILE>` — Entry HTML file name (defaults to `index.html`)
- `--css <MODE>` — CSS delivery strategy: `external` (default) or `inline`

#### `webui inspect`
Inspects a `protocol.bin` file by converting it to JSON and printing to stdout. Useful for debugging and piping to `jq`.
Expand Down
4 changes: 4 additions & 0 deletions crates/webui-parser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ authors.workspace = true
license.workspace = true
repository.workspace = true

[features]
default = ["fs"]
fs = []

[dependencies]
webui-expressions = { path = "../webui-expressions" }
webui-protocol = { path = "../webui-protocol" }
Expand Down
8 changes: 7 additions & 1 deletion crates/webui-parser/src/component_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

use crate::{ParserError, Result};
use std::collections::HashMap;
#[cfg(feature = "fs")]
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(feature = "fs")]
use std::path::Path;
use std::path::PathBuf;
#[cfg(feature = "fs")]
use walkdir::WalkDir;

/// Represents a web component in the registry.
Expand Down Expand Up @@ -43,6 +47,7 @@ impl ComponentRegistry {
}

/// Register multiple components from directories recursively.
#[cfg(feature = "fs")]
pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
for dir in directories {
for entry in WalkDir::new(dir.as_ref())
Expand Down Expand Up @@ -78,6 +83,7 @@ impl ComponentRegistry {
}

/// Register a web component from paths to HTML and CSS files.
#[cfg(feature = "fs")]
pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
&mut self,
html_path: P,
Expand Down
117 changes: 98 additions & 19 deletions crates/webui-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ use webui_protocol::{
WebUIFragmentAttribute, WebUIFragmentRecords,
};

/// Strategy for how component CSS is delivered in rendered output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CssStrategy {
/// Emit `<link rel="stylesheet" href="./component.css">` tags (default).
#[default]
External,
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
Inline,
}

/// Counter for generating unique fragment IDs.
struct FragmentIdCounter {
/// Map of counter types to their current values.
Expand Down Expand Up @@ -74,6 +84,9 @@ pub struct HtmlParser {

/// Buffer for accumulating raw content
raw_buffer: String,

/// How component CSS is delivered in output.
css_strategy: CssStrategy,
}

impl HtmlParser {
Expand All @@ -92,10 +105,17 @@ impl HtmlParser {
handlebars_parser: HandlebarsParser::new(),
raw_buffer: String::new(),
fragment_records: WebUIFragmentRecords::new(),
css_strategy: CssStrategy::default(),
parser,
}
}

/// Set the CSS strategy for component stylesheet delivery.
pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
self.css_strategy = strategy;
self
}

/// Get a mutable reference to the component registry.
pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
&mut self.component_registry
Expand Down Expand Up @@ -940,13 +960,23 @@ impl HtmlParser {

// Parse and register component template if not already done
if !self.fragment_records.contains_key(tag_name) {
let has_css = css_content.is_some();
let css_path = if has_css {
Some(format!("{}.css", tag_name))
} else {
None
let css_injection = match self.css_strategy {
CssStrategy::External => {
if css_content.is_some() {
Some(format!(
"<link rel=\"stylesheet\" href=\"./{}.css\">",
tag_name
))
} else {
None
}
}
CssStrategy::Inline => css_content
.as_ref()
.map(|css| format!("<style>{}</style>", css.trim())),
};
let processed = self.process_component_template(&html_content, css_path.as_deref());
let processed =
self.process_component_template(&html_content, css_injection.as_deref());
self.parse(tag_name, &processed)?;
}

Expand All @@ -972,32 +1002,27 @@ impl HtmlParser {
}

/// Process component template HTML: wrap in shadow DOM template if needed,
/// inject stylesheet link, and strip runtime-only attributes.
fn process_component_template(&mut self, html: &str, styles: Option<&str>) -> String {
/// inject CSS snippet (link or inline style), and strip runtime-only attributes.
fn process_component_template(&mut self, html: &str, css_snippet: Option<&str>) -> String {
let trimmed = html.trim();
let has_template = trimmed.starts_with("<template");

if has_template {
// Strip @/:/?-prefixed attributes from the template tag
let stripped = self.strip_runtime_attrs_from_template(trimmed);
if let Some(style_path) = styles {
// Inject link after the first > in the template tag
if let Some(snippet) = css_snippet {
// Inject CSS snippet after the first > in the template tag
if let Some(pos) = stripped.find('>') {
let mut result = String::with_capacity(stripped.len() + style_path.len() + 50);
let mut result = String::with_capacity(stripped.len() + snippet.len() + 16);
result.push_str(&stripped[..=pos]);
result.push_str(&format!(
"<link rel=\"stylesheet\" href=\"./{}\">",
style_path
));
result.push_str(snippet);
result.push_str(&stripped[pos + 1..]);
return result;
}
}
stripped
} else if let Some(style_path) = styles {
format!(
"<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"./{style_path}\">{trimmed}</template>"
)
} else if let Some(snippet) = css_snippet {
format!("<template shadowrootmode=\"open\">{snippet}{trimmed}</template>")
} else {
format!("<template shadowrootmode=\"open\">{trimmed}</template>")
}
Expand Down Expand Up @@ -2257,4 +2282,58 @@ mod tests {
raw.value.contains("</body>") && raw.value.contains("</html>"))
);
}

#[test]
fn test_css_strategy_external_emits_link_tag() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
.ok();
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let raw_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
raw_text.contains(r#"<link rel="stylesheet" href="./my-card.css">"#),
"Expected external <link> tag in: {}",
raw_text
);
}

#[test]
fn test_css_strategy_inline_emits_style_tag() {
let mut parser = HtmlParser::new();
parser.set_css_strategy(CssStrategy::Inline);
parser
.component_registry_mut()
.register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
.ok();
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let raw_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
raw_text.contains("<style>p { color: red; }</style>"),
"Expected inline <style> tag in: {}",
raw_text
);
assert!(
!raw_text.contains("<link"),
"Should not have <link> tag in inline mode: {}",
raw_text
);
}
}
19 changes: 16 additions & 3 deletions docs/guide/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `webui` binary will be available at `target/release/webui`.
Build a WebUI application from an app folder.

```bash
webui build [APP] --out <OUT> [--entry <FILE>]
webui build [APP] --out <OUT> [--entry <FILE>] [--css <MODE>]
```

**Arguments:**
Expand All @@ -31,6 +31,14 @@ webui build [APP] --out <OUT> [--entry <FILE>]
| `APP` | Path to the app folder | `.` (current directory) |
| `--out <OUT>` | Output folder for protocol and assets | *(required)* |
| `--entry <FILE>` | Entry HTML file name | `index.html` |
| `--css <MODE>` | CSS delivery strategy: `external` or `inline` | `external` |

**CSS Modes:**

| Mode | Behavior |
|------|----------|
| `external` | Emits `<link>` tags referencing external `.css` files. CSS files are copied to the output folder. |
| `inline` | Embeds CSS content directly in `<style>` tags inside shadow DOM templates. No separate CSS files are written. |

**Examples:**

Expand All @@ -43,6 +51,9 @@ webui build ./my-app --out ./dist

# Use a custom entry file
webui build ./my-app --out ./dist --entry home.html

# Build with inline CSS (no external CSS files)
webui build ./my-app --out ./dist --css inline
```

### `webui inspect`
Expand Down Expand Up @@ -128,10 +139,12 @@ The `--out` folder will contain:
```
dist/
├── protocol.bin # The WebUI protocol (protobuf binary)
├── my-card.css # Component CSS (copied)
└── nav-bar.css # Component CSS (copied)
├── my-card.css # Component CSS (--css external only)
└── nav-bar.css # Component CSS (--css external only)
```

With `--css inline`, only `protocol.bin` is written — CSS is embedded directly in the protocol's template fragments.

### protocol.bin

The protocol file contains a serialized `WebUIProtocol` structure (protobuf binary) with all parsed fragments. This file is consumed by a [platform handler](/guide/concepts/handlers/) at runtime to render HTML with your application state.
Expand Down