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
31 changes: 16 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 20 additions & 17 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ pub struct ComponentData {
pub template: String,
/// Component CSS content for the Module strategy.
pub css: String,
/// External stylesheet href for the Link CSS strategy (e.g. "/my-card.css").
/// External stylesheet href for the Link CSS strategy.
/// Default format is `<component-name>.css`, but build-time naming
/// templates can produce hashed names (e.g. `my-card-a1b2c3d4.css`) and/or
/// prepend a CDN/public base URL.
/// Always set when CssStrategy::Link is active and the component has CSS.
/// Empty for Style/Module strategies and for components without CSS.
/// The handler uses `css_strategy` and `dom_strategy` on `WebUIProtocol` to
Expand Down Expand Up @@ -441,19 +444,19 @@ pub struct Predicate {
- 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.

## State Management (webui-state)
### Path Resolution
The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract:
```rust
pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>>
```
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`.

### Requirements
- Dot notation support (e.g., user.profile.name)
- Special length property support for arrays and strings (e.g., users.length)
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
- Nullable path handling via `Option`
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false
### Path Resolution
The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract:
```rust
pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>>
```
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`.
### Requirements
- Dot notation support (e.g., user.profile.name)
- Special length property support for arrays and strings (e.g., users.length)
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
- Nullable path handling via `Option`
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false

## Expression Evaluation (webui-expressions)
### Core Function
Expand Down Expand Up @@ -949,11 +952,11 @@ pub enum CssStrategy {
}
```

- **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.
- **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.
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
- **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.

Set via `parser.set_css_strategy(CssStrategy::Style)`.
Set at construction time with `HtmlParser::with_options(ParserOptions::try_new(...))`.

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

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

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

10 changes: 8 additions & 2 deletions crates/webui-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This installs the `webui` binary.
Build a WebUI application into a compiled protocol and CSS files.

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

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

```bash
webui build ./src --out ./dist
webui build ./src --out ./dist --plugin webui --css style
webui build ./src --out ./dist --css-file-name-template "[name]-[hash].[ext]"
webui build ./src --out ./dist --css-file-name-template "[name]-[hash].[ext]" --css-public-base "https://cdn.example.com/assets"
```

### `webui serve`

Start a development server with live rebuild and HMR.

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

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

```bash
webui serve ./src --state ./data/state.json --port 3000 --watch
Expand Down
10 changes: 10 additions & 0 deletions crates/webui-cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub fn build(app: &std::path::Path, out: &std::path::Path, entry: &str) -> Resul
dom: DomStrategy::Shadow,
plugin: None,
components: Vec::new(),
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
},
out: out.to_path_buf(),
})
Expand Down Expand Up @@ -210,6 +212,8 @@ mod tests {
dom: DomStrategy::Shadow,
plugin: None,
components: Vec::new(),
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
},
out: out_dir.path().to_path_buf(),
})
Expand Down Expand Up @@ -326,6 +330,8 @@ mod tests {
dom: DomStrategy::Shadow,
plugin: None,
components: vec![ext_path],
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
},
out: out_dir.path().to_path_buf(),
})
Expand Down Expand Up @@ -406,6 +412,8 @@ mod tests {
dom: DomStrategy::Shadow,
plugin: None,
components: vec!["test-widget".to_string()],
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
},
out: out_dir.path().to_path_buf(),
})
Expand Down Expand Up @@ -483,6 +491,8 @@ mod tests {
dom: DomStrategy::Shadow,
plugin: None,
components: vec!["@myui".to_string()],
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
},
out: out_dir.path().to_path_buf(),
})
Expand Down
37 changes: 37 additions & 0 deletions crates/webui-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::path::PathBuf;
pub use webui::CssStrategy;
pub use webui::DomStrategy;
pub use webui::Plugin;
pub use webui::DEFAULT_CSS_FILE_NAME_TEMPLATE;

/// Shared CLI arguments used by both `build` and `serve` commands.
#[derive(Args, Clone)]
Expand Down Expand Up @@ -33,6 +34,14 @@ pub struct AppArgs {
/// Additional component sources (npm packages or local paths, repeatable)
#[arg(long, value_name = "SOURCE")]
pub components: Vec<String>,

/// Link-mode CSS filename template using [name], [hash], [ext]
#[arg(long, default_value = DEFAULT_CSS_FILE_NAME_TEMPLATE)]
pub css_file_name_template: String,

/// Optional base URL/path prefix for Link-mode css hrefs
#[arg(long)]
pub css_public_base: Option<String>,
}

impl AppArgs {
Expand All @@ -45,6 +54,34 @@ impl AppArgs {
dom: self.dom,
plugin: self.plugin,
components: self.components.clone(),
css_file_name_template: self.css_file_name_template.clone(),
css_public_base: self.css_public_base.clone(),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn to_build_options_passes_css_file_output_settings() {
let args = AppArgs {
app: std::path::PathBuf::from("."),
entry: "index.html".to_string(),
css: CssStrategy::Link,
dom: DomStrategy::Shadow,
plugin: None,
components: Vec::new(),
css_file_name_template: "[name]-[hash].[ext]".to_string(),
css_public_base: Some("https://cdn.example.com/assets".to_string()),
};
let options = args.to_build_options(std::path::Path::new("."));

assert_eq!(options.css_file_name_template, "[name]-[hash].[ext]");
assert_eq!(
options.css_public_base.as_deref(),
Some("https://cdn.example.com/assets")
);
}
}
Loading
Loading