Skip to content

Commit aa932dd

Browse files
feat: add CssStrategy enum for external and inline css (#51)
* feat: add CssStrategy enum for external and inline component CSS delivery Introduce CssStrategy to control how component stylesheets are emitted during parsing: - External (default): emits <link> tags referencing .css files - Inline: embeds CSS content in <style> tags within shadow DOM The strategy is set on HtmlParser via set_css_strategy() and affects process_component_template output. The webui-cli exposes this as --css <external|inline> on the build command. Also feature-gates walkdir/filesystem methods behind the 'fs' feature so webui-parser compiles for wasm32 without filesystem dependencies. * fix crate
1 parent 611facb commit aa932dd

5 files changed

Lines changed: 147 additions & 25 deletions

File tree

DESIGN.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,27 @@ pub struct HtmlParser {
331331
css_parser: CssParser,
332332
condition_parser: ConditionParser,
333333
handlebars_parser: HandlebarsParser,
334+
css_strategy: CssStrategy,
334335
// Other fields...
335336
}
336337
```
338+
339+
#### CSS Strategy
340+
```rust
341+
/// Strategy for how component CSS is delivered in rendered output.
342+
pub enum CssStrategy {
343+
/// Emit `<link rel="stylesheet" href="./component.css">` tags (default).
344+
External,
345+
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
346+
Inline,
347+
}
348+
```
349+
350+
- **External** (default): Emits `<link>` tags referencing external `.css` files. Used by the CLI for production builds where CSS files are served separately.
351+
- **Inline**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
352+
353+
Set via `parser.set_css_strategy(CssStrategy::Inline)`.
354+
337355
#### Primary Method
338356
```rust
339357
pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<(), ParserError>
@@ -462,7 +480,8 @@ webui/
462480
│ ├── webui-parser/ # HTML/CSS/template parser
463481
│ ├── webui-protocol/ # Protocol definition
464482
│ ├── webui-state/ # State management
465-
│ └── webui-test-utils/ # Testing utilities
483+
│ ├── webui-test-utils/ # Testing utilities
484+
│ └── webui-wasm/ # WebAssembly bindings
466485
├── examples/ # Example applications
467486
├── docs/ # Documentation
468487
├── tests/ # Integration tests
@@ -489,13 +508,14 @@ The `webui` CLI provides the developer-facing build toolchain for WebUI applicat
489508
Builds a WebUI application from an app folder into the protocol format.
490509

491510
```bash
492-
webui build [APP] --out <OUT> [--entry <FILE>]
511+
webui build [APP] --out <OUT> [--entry <FILE>] [--css <MODE>]
493512
```
494513

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

500520
#### `webui inspect`
501521
Inspects a `protocol.bin` file by converting it to JSON and printing to stdout. Useful for debugging and piping to `jq`.

crates/webui-parser/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ authors.workspace = true
77
license.workspace = true
88
repository.workspace = true
99

10+
[features]
11+
default = ["fs"]
12+
fs = []
13+
1014
[dependencies]
1115
webui-expressions = { path = "../webui-expressions" }
1216
webui-protocol = { path = "../webui-protocol" }

crates/webui-parser/src/component_registry.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
55
use crate::{ParserError, Result};
66
use std::collections::HashMap;
7+
#[cfg(feature = "fs")]
78
use std::fs;
8-
use std::path::{Path, PathBuf};
9+
#[cfg(feature = "fs")]
10+
use std::path::Path;
11+
use std::path::PathBuf;
12+
#[cfg(feature = "fs")]
913
use walkdir::WalkDir;
1014

1115
/// Represents a web component in the registry.
@@ -43,6 +47,7 @@ impl ComponentRegistry {
4347
}
4448

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

8085
/// Register a web component from paths to HTML and CSS files.
86+
#[cfg(feature = "fs")]
8187
pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
8288
&mut self,
8389
html_path: P,

crates/webui-parser/src/lib.rs

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ use webui_protocol::{
2121
WebUIFragmentAttribute, WebUIFragmentRecords,
2222
};
2323

24+
/// Strategy for how component CSS is delivered in rendered output.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26+
pub enum CssStrategy {
27+
/// Emit `<link rel="stylesheet" href="./component.css">` tags (default).
28+
#[default]
29+
External,
30+
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
31+
Inline,
32+
}
33+
2434
/// Counter for generating unique fragment IDs.
2535
struct FragmentIdCounter {
2636
/// Map of counter types to their current values.
@@ -74,6 +84,9 @@ pub struct HtmlParser {
7484

7585
/// Buffer for accumulating raw content
7686
raw_buffer: String,
87+
88+
/// How component CSS is delivered in output.
89+
css_strategy: CssStrategy,
7790
}
7891

7992
impl HtmlParser {
@@ -92,10 +105,17 @@ impl HtmlParser {
92105
handlebars_parser: HandlebarsParser::new(),
93106
raw_buffer: String::new(),
94107
fragment_records: WebUIFragmentRecords::new(),
108+
css_strategy: CssStrategy::default(),
95109
parser,
96110
}
97111
}
98112

113+
/// Set the CSS strategy for component stylesheet delivery.
114+
pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
115+
self.css_strategy = strategy;
116+
self
117+
}
118+
99119
/// Get a mutable reference to the component registry.
100120
pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
101121
&mut self.component_registry
@@ -940,13 +960,23 @@ impl HtmlParser {
940960

941961
// Parse and register component template if not already done
942962
if !self.fragment_records.contains_key(tag_name) {
943-
let has_css = css_content.is_some();
944-
let css_path = if has_css {
945-
Some(format!("{}.css", tag_name))
946-
} else {
947-
None
963+
let css_injection = match self.css_strategy {
964+
CssStrategy::External => {
965+
if css_content.is_some() {
966+
Some(format!(
967+
"<link rel=\"stylesheet\" href=\"./{}.css\">",
968+
tag_name
969+
))
970+
} else {
971+
None
972+
}
973+
}
974+
CssStrategy::Inline => css_content
975+
.as_ref()
976+
.map(|css| format!("<style>{}</style>", css.trim())),
948977
};
949-
let processed = self.process_component_template(&html_content, css_path.as_deref());
978+
let processed =
979+
self.process_component_template(&html_content, css_injection.as_deref());
950980
self.parse(tag_name, &processed)?;
951981
}
952982

@@ -972,32 +1002,27 @@ impl HtmlParser {
9721002
}
9731003

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

9801010
if has_template {
9811011
// Strip @/:/?-prefixed attributes from the template tag
9821012
let stripped = self.strip_runtime_attrs_from_template(trimmed);
983-
if let Some(style_path) = styles {
984-
// Inject link after the first > in the template tag
1013+
if let Some(snippet) = css_snippet {
1014+
// Inject CSS snippet after the first > in the template tag
9851015
if let Some(pos) = stripped.find('>') {
986-
let mut result = String::with_capacity(stripped.len() + style_path.len() + 50);
1016+
let mut result = String::with_capacity(stripped.len() + snippet.len() + 16);
9871017
result.push_str(&stripped[..=pos]);
988-
result.push_str(&format!(
989-
"<link rel=\"stylesheet\" href=\"./{}\">",
990-
style_path
991-
));
1018+
result.push_str(snippet);
9921019
result.push_str(&stripped[pos + 1..]);
9931020
return result;
9941021
}
9951022
}
9961023
stripped
997-
} else if let Some(style_path) = styles {
998-
format!(
999-
"<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"./{style_path}\">{trimmed}</template>"
1000-
)
1024+
} else if let Some(snippet) = css_snippet {
1025+
format!("<template shadowrootmode=\"open\">{snippet}{trimmed}</template>")
10011026
} else {
10021027
format!("<template shadowrootmode=\"open\">{trimmed}</template>")
10031028
}
@@ -2257,4 +2282,58 @@ mod tests {
22572282
raw.value.contains("</body>") && raw.value.contains("</html>"))
22582283
);
22592284
}
2285+
2286+
#[test]
2287+
fn test_css_strategy_external_emits_link_tag() {
2288+
let mut parser = HtmlParser::new();
2289+
parser
2290+
.component_registry_mut()
2291+
.register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
2292+
.ok();
2293+
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2294+
let records = parser.into_fragment_records();
2295+
let my_card = &records["my-card"].fragments;
2296+
let raw_text: String = my_card
2297+
.iter()
2298+
.filter_map(|f| match &f.fragment {
2299+
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2300+
_ => None,
2301+
})
2302+
.collect();
2303+
assert!(
2304+
raw_text.contains(r#"<link rel="stylesheet" href="./my-card.css">"#),
2305+
"Expected external <link> tag in: {}",
2306+
raw_text
2307+
);
2308+
}
2309+
2310+
#[test]
2311+
fn test_css_strategy_inline_emits_style_tag() {
2312+
let mut parser = HtmlParser::new();
2313+
parser.set_css_strategy(CssStrategy::Inline);
2314+
parser
2315+
.component_registry_mut()
2316+
.register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
2317+
.ok();
2318+
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2319+
let records = parser.into_fragment_records();
2320+
let my_card = &records["my-card"].fragments;
2321+
let raw_text: String = my_card
2322+
.iter()
2323+
.filter_map(|f| match &f.fragment {
2324+
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2325+
_ => None,
2326+
})
2327+
.collect();
2328+
assert!(
2329+
raw_text.contains("<style>p { color: red; }</style>"),
2330+
"Expected inline <style> tag in: {}",
2331+
raw_text
2332+
);
2333+
assert!(
2334+
!raw_text.contains("<link"),
2335+
"Should not have <link> tag in inline mode: {}",
2336+
raw_text
2337+
);
2338+
}
22602339
}

docs/guide/cli/index.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `webui` binary will be available at `target/release/webui`.
2121
Build a WebUI application from an app folder.
2222

2323
```bash
24-
webui build [APP] --out <OUT> [--entry <FILE>]
24+
webui build [APP] --out <OUT> [--entry <FILE>] [--css <MODE>]
2525
```
2626

2727
**Arguments:**
@@ -31,6 +31,14 @@ webui build [APP] --out <OUT> [--entry <FILE>]
3131
| `APP` | Path to the app folder | `.` (current directory) |
3232
| `--out <OUT>` | Output folder for protocol and assets | *(required)* |
3333
| `--entry <FILE>` | Entry HTML file name | `index.html` |
34+
| `--css <MODE>` | CSS delivery strategy: `external` or `inline` | `external` |
35+
36+
**CSS Modes:**
37+
38+
| Mode | Behavior |
39+
|------|----------|
40+
| `external` | Emits `<link>` tags referencing external `.css` files. CSS files are copied to the output folder. |
41+
| `inline` | Embeds CSS content directly in `<style>` tags inside shadow DOM templates. No separate CSS files are written. |
3442

3543
**Examples:**
3644

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

4452
# Use a custom entry file
4553
webui build ./my-app --out ./dist --entry home.html
54+
55+
# Build with inline CSS (no external CSS files)
56+
webui build ./my-app --out ./dist --css inline
4657
```
4758

4859
### `webui inspect`
@@ -128,10 +139,12 @@ The `--out` folder will contain:
128139
```
129140
dist/
130141
├── protocol.bin # The WebUI protocol (protobuf binary)
131-
├── my-card.css # Component CSS (copied)
132-
└── nav-bar.css # Component CSS (copied)
142+
├── my-card.css # Component CSS (--css external only)
143+
└── nav-bar.css # Component CSS (--css external only)
133144
```
134145

146+
With `--css inline`, only `protocol.bin` is written — CSS is embedded directly in the protocol's template fragments.
147+
135148
### protocol.bin
136149

137150
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.

0 commit comments

Comments
 (0)