Skip to content

Commit b02af48

Browse files
feat: add configurable CSS asset names (#313)
1 parent cae9f2c commit b02af48

32 files changed

Lines changed: 1057 additions & 250 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DESIGN.md

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ pub struct ComponentData {
5151
pub template: String,
5252
/// Component CSS content for the Module strategy.
5353
pub css: String,
54-
/// External stylesheet href for the Link CSS strategy (e.g. "/my-card.css").
54+
/// External stylesheet href for the Link CSS strategy.
55+
/// Default format is `<component-name>.css`, but build-time naming
56+
/// templates can produce hashed names (e.g. `my-card-a1b2c3d4.css`) and/or
57+
/// prepend a CDN/public base URL.
5558
/// Always set when CssStrategy::Link is active and the component has CSS.
5659
/// Empty for Style/Module strategies and for components without CSS.
5760
/// The handler uses `css_strategy` and `dom_strategy` on `WebUIProtocol` to
@@ -441,19 +444,19 @@ pub struct Predicate {
441444
- Attribute names starting with '?' are treated as boolean attributes using the `Attribute` fragment type with a `condition_tree`. The attribute is rendered only if the condition evaluates to true.
442445

443446
## State Management (webui-state)
444-
### Path Resolution
445-
The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract:
446-
```rust
447-
pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>>
448-
```
449-
Existing JSON values are returned as `Cow::Borrowed` so handler and expression hot paths do not clone the state tree. Synthetic values, currently string and array `.length`, are returned as `Cow::Owned`. The owned `find_value_by_dotted_path(path, state) -> Option<Value>` wrapper is retained for API boundaries that must materialize an owned `serde_json::Value`.
450-
451-
### Requirements
452-
- Dot notation support (e.g., user.profile.name)
453-
- Special length property support for arrays and strings (e.g., users.length)
454-
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
455-
- Nullable path handling via `Option`
456-
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false
447+
### Path Resolution
448+
The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract:
449+
```rust
450+
pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>>
451+
```
452+
Existing JSON values are returned as `Cow::Borrowed` so handler and expression hot paths do not clone the state tree. Synthetic values, currently string and array `.length`, are returned as `Cow::Owned`. The owned `find_value_by_dotted_path(path, state) -> Option<Value>` wrapper is retained for API boundaries that must materialize an owned `serde_json::Value`.
453+
454+
### Requirements
455+
- Dot notation support (e.g., user.profile.name)
456+
- Special length property support for arrays and strings (e.g., users.length)
457+
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
458+
- Nullable path handling via `Option`
459+
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false
457460

458461
## Expression Evaluation (webui-expressions)
459462
### Core Function
@@ -949,11 +952,11 @@ pub enum CssStrategy {
949952
}
950953
```
951954

952-
- **Link** (default): Emits `<link>` tags referencing external `.css` files only for components whose discovery/registration data included CSS. Used by the CLI for production builds where CSS files are served separately.
955+
- **Link** (default): Emits `<link>` tags referencing external `.css` files only for components whose discovery/registration data included CSS. Used by the CLI for production builds where CSS files are served separately. Output filenames are configurable with a naming template (`[name]`, `[hash]`, `[ext]`), defaulting to `[name].[ext]`. `[hash]` is SHA-256 truncated to 8 hex chars. An optional public base prefix can be applied so protocol `css_href` values point to CDN URLs. The resolved href is used consistently for handler-emitted head links and parser/plugin-generated component template stylesheet links.
953956
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
954957
- **Module**: Uses the [Declarative CSS Module Scripts](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md) proposal. During SSR, emits a `<style type="module" specifier="component-name">` definition in each component's light DOM on first render (e.g., `<my-comp><style type="module" ...>CSS</style><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. Components inside false `<if>` blocks or empty `<for>` loops that were not rendered during SSR get their module style definitions emitted at `body_end`, so client-side activation can adopt them. When a CSP nonce is configured (via `RenderOptions::with_nonce` / `webui_handler_set_nonce`), the SSR-emitted `<style type="module">` tags include `nonce="VALUE"` (in `type`, `nonce`, `specifier` order) so strict `style-src 'nonce-...'` policies allow them, matching the existing nonce treatment of inline `<script>` tags. The browser registers the CSS module globally and shares a single `CSSStyleSheet` across all shadow roots that adopt it. No external CSS files are produced. During SPA partial navigation, module style definitions for newly needed components are sent in the `templateStyles` array; the router appends them to `<head>` before executing template scripts. WebUI Framework compiled metadata carries the adopted stylesheet specifier (`sa`) so client-created components can adopt the registered stylesheet on their shadow root.
955958

956-
Set via `parser.set_css_strategy(CssStrategy::Style)`.
959+
Set at construction time with `HtmlParser::with_options(ParserOptions::try_new(...))`.
957960

958961
#### Primary Method
959962
```rust
@@ -1012,6 +1015,7 @@ parser.parse("index.html", &html)?;
10121015
**CLI integration:**
10131016
```bash
10141017
webui build ./templates --out ./dist --plugin=<name>
1018+
webui build ./templates --out ./dist --css-file-name-template="[name]-[hash].[ext]" --css-public-base="https://cdn.example.com/assets"
10151019
webui serve ./templates --state ./data/state.json --plugin=<name>
10161020
```
10171021

@@ -1439,4 +1443,3 @@ The CLI specification and usage details are maintained in [crates/webui-cli/READ
14391443
## Example Workflow
14401444

14411445
Examples and end-to-end walkthroughs are maintained in [examples/README.md](examples/README.md)
1442-

crates/webui-cli/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This installs the `webui` binary.
1717
Build a WebUI application into a compiled protocol and CSS files.
1818

1919
```bash
20-
webui build [APP] --out <DIR> [--entry <FILE>] [--css <MODE>] [--plugin <NAME>]
20+
webui build [APP] --out <DIR> [--entry <FILE>] [--css <MODE>] [--plugin <NAME>] [--css-file-name-template <TEMPLATE>] [--css-public-base <BASE>]
2121
```
2222

2323
| Option | Default | Description |
@@ -27,18 +27,22 @@ webui build [APP] --out <DIR> [--entry <FILE>] [--css <MODE>] [--plugin <NAME>]
2727
| `--entry` | `index.html` | Entry HTML file |
2828
| `--css` | `link` | CSS mode: `link` (external files) or `style` (inline) |
2929
| `--plugin` | *(none)* | Plugin identifier (see [Plugins](https://microsoft.github.io/webui/guide/concepts/plugins/) for available identifiers) |
30+
| `--css-file-name-template` | `[name].[ext]` | Link-mode CSS filename template. Tokens: `[name]`, `[hash]`, `[ext]` |
31+
| `--css-public-base` | *(none)* | Optional base URL/path prepended to Link-mode stylesheet hrefs |
3032

3133
```bash
3234
webui build ./src --out ./dist
3335
webui build ./src --out ./dist --plugin webui --css style
36+
webui build ./src --out ./dist --css-file-name-template "[name]-[hash].[ext]"
37+
webui build ./src --out ./dist --css-file-name-template "[name]-[hash].[ext]" --css-public-base "https://cdn.example.com/assets"
3438
```
3539

3640
### `webui serve`
3741

3842
Start a development server with live rebuild and HMR.
3943

4044
```bash
41-
webui serve [APP] [--state <FILE>] [--servedir <DIR>] [--port <PORT>] [--api-port <PORT>] [--plugin <NAME>] [--watch]
45+
webui serve [APP] [--state <FILE>] [--servedir <DIR>] [--port <PORT>] [--api-port <PORT>] [--plugin <NAME>] [--watch] [--css-file-name-template <TEMPLATE>] [--css-public-base <BASE>]
4246
```
4347

4448
| Option | Default | Description |
@@ -50,6 +54,8 @@ webui serve [APP] [--state <FILE>] [--servedir <DIR>] [--port <PORT>] [--api-por
5054
| `--api-port` | *(none)* | Proxy API requests to this port |
5155
| `--plugin` | *(none)* | Plugin identifier (see [Plugins](https://microsoft.github.io/webui/guide/concepts/plugins/) for available identifiers) |
5256
| `--watch` | off | Enable file watching + HMR |
57+
| `--css-file-name-template` | `[name].[ext]` | Link-mode CSS filename template. Tokens: `[name]`, `[hash]`, `[ext]` |
58+
| `--css-public-base` | *(none)* | Optional base URL/path prepended to Link-mode stylesheet hrefs |
5359

5460
```bash
5561
webui serve ./src --state ./data/state.json --port 3000 --watch

crates/webui-cli/src/commands/build.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ pub fn build(app: &std::path::Path, out: &std::path::Path, entry: &str) -> Resul
107107
dom: DomStrategy::Shadow,
108108
plugin: None,
109109
components: Vec::new(),
110+
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
111+
css_public_base: None,
110112
},
111113
out: out.to_path_buf(),
112114
})
@@ -210,6 +212,8 @@ mod tests {
210212
dom: DomStrategy::Shadow,
211213
plugin: None,
212214
components: Vec::new(),
215+
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
216+
css_public_base: None,
213217
},
214218
out: out_dir.path().to_path_buf(),
215219
})
@@ -326,6 +330,8 @@ mod tests {
326330
dom: DomStrategy::Shadow,
327331
plugin: None,
328332
components: vec![ext_path],
333+
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
334+
css_public_base: None,
329335
},
330336
out: out_dir.path().to_path_buf(),
331337
})
@@ -406,6 +412,8 @@ mod tests {
406412
dom: DomStrategy::Shadow,
407413
plugin: None,
408414
components: vec!["test-widget".to_string()],
415+
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
416+
css_public_base: None,
409417
},
410418
out: out_dir.path().to_path_buf(),
411419
})
@@ -483,6 +491,8 @@ mod tests {
483491
dom: DomStrategy::Shadow,
484492
plugin: None,
485493
components: vec!["@myui".to_string()],
494+
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
495+
css_public_base: None,
486496
},
487497
out: out_dir.path().to_path_buf(),
488498
})

crates/webui-cli/src/commands/common.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::path::PathBuf;
66
pub use webui::CssStrategy;
77
pub use webui::DomStrategy;
88
pub use webui::Plugin;
9+
pub use webui::DEFAULT_CSS_FILE_NAME_TEMPLATE;
910

1011
/// Shared CLI arguments used by both `build` and `serve` commands.
1112
#[derive(Args, Clone)]
@@ -33,6 +34,14 @@ pub struct AppArgs {
3334
/// Additional component sources (npm packages or local paths, repeatable)
3435
#[arg(long, value_name = "SOURCE")]
3536
pub components: Vec<String>,
37+
38+
/// Link-mode CSS filename template using [name], [hash], [ext]
39+
#[arg(long, default_value = DEFAULT_CSS_FILE_NAME_TEMPLATE)]
40+
pub css_file_name_template: String,
41+
42+
/// Optional base URL/path prefix for Link-mode css hrefs
43+
#[arg(long)]
44+
pub css_public_base: Option<String>,
3645
}
3746

3847
impl AppArgs {
@@ -45,6 +54,34 @@ impl AppArgs {
4554
dom: self.dom,
4655
plugin: self.plugin,
4756
components: self.components.clone(),
57+
css_file_name_template: self.css_file_name_template.clone(),
58+
css_public_base: self.css_public_base.clone(),
4859
}
4960
}
5061
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
67+
#[test]
68+
fn to_build_options_passes_css_file_output_settings() {
69+
let args = AppArgs {
70+
app: std::path::PathBuf::from("."),
71+
entry: "index.html".to_string(),
72+
css: CssStrategy::Link,
73+
dom: DomStrategy::Shadow,
74+
plugin: None,
75+
components: Vec::new(),
76+
css_file_name_template: "[name]-[hash].[ext]".to_string(),
77+
css_public_base: Some("https://cdn.example.com/assets".to_string()),
78+
};
79+
let options = args.to_build_options(std::path::Path::new("."));
80+
81+
assert_eq!(options.css_file_name_template, "[name]-[hash].[ext]");
82+
assert_eq!(
83+
options.css_public_base.as_deref(),
84+
Some("https://cdn.example.com/assets")
85+
);
86+
}
87+
}

0 commit comments

Comments
 (0)