Skip to content

Commit 6ffc7ee

Browse files
feat: add CSS theme token validation (#372)
1 parent 0116e3e commit 6ffc7ee

35 files changed

Lines changed: 3213 additions & 498 deletions

File tree

Cargo.lock

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

DESIGN.md

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,10 @@ own asset loader rather than making the core `@microsoft/webui` package know
349349
plugin details. Asset roots are parsed into the protocol through synthetic
350350
non-entry fragments, so they do not become reachable from the SSR entry tree and
351351
are not included in the initial SSR bootstrap unless the entry graph also
352-
references them. Asset generation is parallelized across requested roots. Each root produces one
352+
references them. `webui serve --emit-component-assets` parses and validates the
353+
same roots on every dev build — surfacing their HTML and theme-token errors even
354+
though they are outside the SSR tree — and serves the compiled modules from
355+
memory. Asset generation is parallelized across requested roots. Each root produces one
353356
standard ESM module, `<tag>.webui.js`, by default. Use
354357
`--asset-file-name-template "[name]-[hash].[ext]"` for CDN-cacheable CSS and
355358
component asset names; `[hash]` is the emitted file's SHA-256 content hash
@@ -1110,12 +1113,36 @@ webui build ./templates --out ./dist --asset-file-name-template="[name]-[hash].[
11101113
webui build ./templates --out ./dist --plugin=webui --emit-component-assets mail-thread,compose-page
11111114
webui build ./templates --out ./dist --plugin=webui --emit-component-assets mail-thread --asset-file-name-template="[name]-[hash].[ext]"
11121115
webui serve ./templates --state ./data/state.json --plugin=<name>
1116+
webui serve ./templates --state ./data/state.json --plugin=webui --emit-component-assets mail-thread,compose-page --watch
11131117
```
11141118

11151119
`webui serve` performs a preflight bind check on its configured HTTP port and
11161120
fails before the initial build if that port is already in use, returning an
11171121
actionable message so stale dev processes can be stopped explicitly.
11181122

1123+
In `webui serve --watch`, the file watcher is **content-aware**: it hashes each
1124+
changed file and drops events whose bytes are unchanged, so a no-op save
1125+
(repeated Ctrl+S that rewrites identical content) triggers no rebuild in the
1126+
clean state. While a rebuild error is active, unchanged events are forwarded so a
1127+
no-op save can retry transient failures without forcing a real content edit.
1128+
Deletions and oversized files always count as changed. Each rebuild's terminal
1129+
line names the triggering file (`↻ rebuilt app-shell.css …`, or `… (+N more)`).
1130+
Incremental rebuild failures are retained in dev-server state. The rebuild
1131+
worker reports the error to the terminal and live-reload SSE; subsequent browser
1132+
refreshes, route renders, JSON partial requests, and component template requests
1133+
return the latest rebuild error instead of stale output. HTML error pages keep
1134+
the live-reload client connected, and JSON partial requests return the rebuild
1135+
error before resolving file/API state. A successful rebuild clears the stored
1136+
error and updates the served protocol/HTML. Non-fatal build advisories (e.g. a
1137+
literal-fallback CSS token absent from every theme) are warning-severity
1138+
`Diagnostic`s rendered with the same `--> file:line:column` + snippet + `help:`
1139+
layout as errors, framed with surrounding blank lines so consecutive
1140+
errors/warnings stay readable. They print under the rebuild line but are
1141+
**deduplicated**: a warning is printed when it first appears (or reappears after
1142+
being resolved), not on every rebuild, so editing an unrelated file does not
1143+
re-spam unchanged advisories. Errors are not deduplicated — a broken build is
1144+
surfaced on every rebuild attempt.
1145+
11191146
#### Content Processing
11201147

11211148
##### Raw Content
@@ -1200,6 +1227,9 @@ impl CssParser {
12001227
- Reject malformed CSS at build time with `ParserError::Css`, including
12011228
unterminated `var()` calls, block comments, strings, and unmatched braces,
12021229
parentheses, or brackets.
1230+
- Exclude any token that is defined by local CSS before validating theme
1231+
coverage. For example, `--foo: var(--token-a, var(--token-b))` reports
1232+
`token-b` only when `--token-a` is defined in the same CSS input.
12031233

12041234
### HTML Scanner
12051235

@@ -1242,7 +1272,7 @@ child range pushes an explicit parse operation, and directive bodies (`<for>`,
12421272

12431273
### CSS Token Hoisting
12441274

1245-
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.
1275+
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 can still need after local CSS definitions are considered.
12461276

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

@@ -1255,18 +1285,28 @@ The `extract_tokens` method uses a deterministic CSS scanner to extract custom p
12551285

12561286
**Excluded (not hoisted):**
12571287
- `--bar: 12px` — local custom property definitions
1258-
- `var(--bar)` when `--bar` is defined in the same CSS file
1288+
- `var(--bar)` when `--bar` is defined in the same CSS file or by an ancestor
1289+
component/root CSS scope
12591290

12601291
The scanner tracks nested `var()` fallback expressions, so nested fallbacks are naturally handled.
12611292

12621293
#### Token Collection During Parsing
12631294

1264-
The `HtmlParser` maintains a `token_store: HashSet<String>` that accumulates tokens from two sources:
1295+
The `HtmlParser` records CSS fallback-chain requirements and custom-property
1296+
definitions from two sources:
12651297

1266-
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.
1298+
1. **Component CSS**component registration stores each component's
1299+
pre-extracted `css_fallback_chains` and `css_definitions`.
12671300
2. **Inline `<style>` tags**when the parser processes a `<style>` tag, it extracts token usages and definitions while stripping removable CSS comments in the same scanner pass.
12681301

1269-
After parsing completes, `HtmlParser::take_tokens()` returns the sorted, deduplicated token list for inclusion in the protocol.
1302+
After parsing completes, `HtmlParser::token_analysis()` walks the parsed fragment
1303+
graph iteratively from each entry/root fragment and returns `CssTokenAnalysis {
1304+
protocol_tokens, fallback_chains }`. The walk carries a counted set of CSS
1305+
custom-property definitions from the entry/root through component boundaries,
1306+
because CSS custom properties inherit through Shadow DOM. Each token candidate
1307+
in a fallback chain such as `var(--a, var(--b, var(--c)))` is removed when that
1308+
token is defined by the current or ancestor CSS scope; any remaining candidates
1309+
contribute to the sorted protocol token list.
12701310

12711311
#### Comment Handling
12721312

@@ -1287,7 +1327,7 @@ the comment wrapper so the CSS parser can distinguish them from invalid CSS.
12871327

12881328
### Design Token Resolution (`webui-tokens`)
12891329

1290-
The `webui-tokens` crate provides serve-time resolution of design token values. While the parser extracts token **names** into the protocol at build time, the token resolver loads token **values** from a theme file and generates CSS declarations for injection into state.
1330+
The `webui-tokens` crate provides build/serve-time validation and resolution of design token values. While the parser extracts token **names** and `var()` fallback chains, the token crate owns the theme-coverage policy: `validate_chain_tokens` decides which chain candidates a theme must provide (literal-fallback chains are exempt) and `unthemed_literal_fallback_tokens` reports likely typos, while `resolve_tokens` generates CSS declarations for injection into state. The parser only adapts the resulting [`webui_tokens::TokenError`] into a structured `Diagnostic`.
12911331

12921332
#### Theme File Format
12931333

@@ -1309,27 +1349,42 @@ Token names omit the `--` prefix (matching the `protocol.tokens` format). Flat s
13091349
```
13101350
load_token_file(path) → TokenFile
13111351
1312-
resolve_tokens(protocol.tokens, token_file) → ResolvedTokens { css, warnings }
1352+
CssTokenAnalysis::validate_theme_tokens(token_file) → Result<()>
1353+
1354+
resolve_tokens(protocol.tokens, token_file) → ResolvedTokens { css }
13131355
13141356
inject_token_css(state, css) → state["tokens"]["light"] = "..."
13151357
```
13161358

1317-
1. **Filter**: Only tokens in `protocol.tokens` are kept.
1318-
2. **Dependency closure**: Token values referencing other tokens via `var(--x)` trigger transitive inclusion. Uses an iterative BFS expansion followed by DFS cycle detection.
1359+
1. **Validate**: Every *required* token must exist in every theme. A token is required when it appears in at least one unresolved `var()` chain with no literal CSS fallback. Local and ancestor CSS definitions are removed before validation, so `--token-a: red; --foo: var(--token-a, var(--token-b))` requires `token-b` from the theme but not `token-a`. A literal-terminated chain such as `var(--brand, #000)` is exempt — `--brand` stays in `protocol.tokens` for runtime resolution (the theme value still wins when present) but does not fail the build when the theme omits it. The same token referenced once with a bare `var(--brand)` and once as `var(--brand, #000)` is still required (the bare usage has no fallback). Missing required tokens fail with `missing-theme-token`. Theme token values are trusted: unresolved or cyclic `var(--x)` references inside the theme remain browser CSS semantics rather than build failures.
1360+
2. **Dependency closure**: Token values referencing other tokens via `var(--x)` trigger transitive inclusion when the referenced token is present in the same theme. Missing transitive references are left in the CSS value as authored.
13191361
3. **CSS generation**: Sorted `--name: value;` declarations. Output is deterministic.
13201362
4. **State injection**: Per-theme CSS strings are set on `state.tokens.<theme>`, where `/*{{{tokens.<theme>}}}*/` signals resolve them during rendering. These render-only token strings are omitted from the emitted `webui-data` client bootstrap.
13211363

1364+
A token used **only** with a literal `var()` fallback and defined in no theme (e.g. a misspelled `var(--colr-brand, #000)`) is reported as a non-fatal `unthemed-token` warning in `BuildResult::warnings` (a `Vec<Diagnostic>`) rather than failing the build. These are warning-severity `Diagnostic`s carrying location, snippet, and a `did you mean …?` suggestion, so `webui build` and `webui serve` render them with the same layout as errors; Node receives their plain `Display` text.
1365+
13221366
#### Package Resolution (`resolve_theme_path`)
13231367

13241368
The CLI `--theme` flag accepts a file path or an npm package name:
13251369

13261370
```bash
1371+
webui build ./src --out ./dist --theme=@microsoft/webui-examples-theme
13271372
webui serve ./src --theme=@microsoft/webui-examples-theme
13281373
webui serve ./src --theme=./my-theme.json
13291374
```
13301375

13311376
Package names are resolved by walking up from `search_root` looking for `node_modules/<pkg>/tokens.json`. Scoped packages (`@scope/name`) and explicit subpaths (`@scope/name/custom.json`) are supported.
13321377

1378+
`BuildOptions::theme` accepts a loaded `TokenFile`. When present, `webui::build`
1379+
validates parser-discovered unresolved tokens before protocol serialization and
1380+
returns `WebUIError::Parse { source: ParserError::Template(..) }` when required
1381+
tokens are missing from the theme. CLI `webui build --theme`, `webui serve
1382+
--theme`, and Node `build({ theme })` all use this same build validation path.
1383+
1384+
When `webui serve --watch` hits one of these theme-token validation failures
1385+
during an incremental rebuild, the failure is retained as the current dev-server
1386+
state so refreshes keep showing the diagnostic until the next successful rebuild.
1387+
13331388
### Error Handling
13341389
```rust
13351390
#[derive(Debug, Error)]

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ pub struct BuildArgs {
2727
/// Comma-separated root component tags to emit as static CDN-loadable assets
2828
#[arg(long, value_delimiter = ',', value_name = "TAGS")]
2929
pub emit_component_assets: Vec<String>,
30+
31+
/// Design token theme to validate against: a JSON file path or npm package name.
32+
/// Missing unresolved CSS tokens fail the build.
33+
#[arg(long)]
34+
pub theme: Option<String>,
3035
}
3136

3237
/// Resolve the `--out` argument into `(output_directory, protocol_filename)`.
@@ -130,10 +135,18 @@ fn run(args: &BuildArgs) -> Result<()> {
130135
if !args.emit_component_assets.is_empty() {
131136
output::field("Component assets", &args.emit_component_assets.join(", "));
132137
}
138+
if let Some(ref theme) = args.theme {
139+
output::field("Theme", theme);
140+
}
133141
eprintln!();
134142

135143
let mut build_options = args.app_args.to_build_options(&app);
136144
build_options.component_asset_roots = args.emit_component_assets.clone();
145+
build_options.theme = args
146+
.theme
147+
.as_deref()
148+
.map(|theme| load_theme(theme, &app))
149+
.transpose()?;
137150
let result = webui::build(build_options).with_context(|| "Build failed")?;
138151
validate_output_file_names(&protocol_name, &result)?;
139152

@@ -195,6 +208,10 @@ fn run(args: &BuildArgs) -> Result<()> {
195208
console::style(Path::new(&protocol_name).display()).bold()
196209
));
197210

211+
for advisory in &result.warnings {
212+
output::warning_diagnostic(advisory);
213+
}
214+
198215
output::finish(&format!(
199216
"Build complete ({} file{} written) {}",
200217
console::style(files_written).bold(),
@@ -222,6 +239,7 @@ pub fn build(app: &std::path::Path, out: &std::path::Path, entry: &str) -> Resul
222239
},
223240
out: out.to_path_buf(),
224241
emit_component_assets: Vec::new(),
242+
theme: None,
225243
})
226244
}
227245

@@ -329,6 +347,7 @@ mod tests {
329347
},
330348
out: out_dir.path().to_path_buf(),
331349
emit_component_assets: Vec::new(),
350+
theme: None,
332351
})
333352
.unwrap();
334353

@@ -364,6 +383,7 @@ mod tests {
364383
},
365384
out: out_dir.path().to_path_buf(),
366385
emit_component_assets: vec!["mail-thread".to_string()],
386+
theme: None,
367387
})
368388
.unwrap();
369389

@@ -416,6 +436,7 @@ mod tests {
416436
},
417437
out: out_dir.path().to_path_buf(),
418438
emit_component_assets: vec!["mail-thread".to_string(), "mail-thread".to_string()],
439+
theme: None,
419440
});
420441

421442
assert!(result.is_err());
@@ -445,6 +466,7 @@ mod tests {
445466
},
446467
out: out_dir.path().to_path_buf(),
447468
emit_component_assets: vec!["fast-card".to_string()],
469+
theme: None,
448470
})
449471
.unwrap();
450472

@@ -482,6 +504,7 @@ mod tests {
482504
},
483505
out: out_dir.path().to_path_buf(),
484506
emit_component_assets: vec!["mail-thread".to_string()],
507+
theme: None,
485508
})
486509
.unwrap();
487510

@@ -617,6 +640,7 @@ mod tests {
617640
},
618641
out: out_dir.path().to_path_buf(),
619642
emit_component_assets: Vec::new(),
643+
theme: None,
620644
})
621645
.unwrap();
622646

@@ -701,6 +725,7 @@ mod tests {
701725
},
702726
out: out_dir.path().to_path_buf(),
703727
emit_component_assets: Vec::new(),
728+
theme: None,
704729
})
705730
.unwrap();
706731

@@ -782,6 +807,7 @@ mod tests {
782807
},
783808
out: out_dir.path().to_path_buf(),
784809
emit_component_assets: Vec::new(),
810+
theme: None,
785811
})
786812
.unwrap();
787813

@@ -808,6 +834,48 @@ mod tests {
808834
assert_eq!(protocol.tokens, vec!["spacing-m", "text-color"]);
809835
}
810836

837+
#[test]
838+
fn test_build_theme_missing_token_fails() {
839+
let app_dir = create_app_dir(&[
840+
("index.html", "<my-btn></my-btn>"),
841+
("my-btn.html", "<button><slot></slot></button>"),
842+
(
843+
"my-btn.css",
844+
":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
845+
),
846+
("theme.json", r#"{"themes":{"light":{"token-b":"green"}}}"#),
847+
]);
848+
let out_dir = TempDir::new().unwrap();
849+
let result = run(&BuildArgs {
850+
app_args: AppArgs {
851+
app: app_dir.path().to_path_buf(),
852+
entry: "index.html".to_string(),
853+
css: CssStrategy::Link,
854+
dom: DomStrategy::Shadow,
855+
plugin: None,
856+
components: Vec::new(),
857+
asset_file_name_template: DEFAULT_ASSET_FILE_NAME_TEMPLATE.to_string(),
858+
css_public_base: None,
859+
legal_comments: LegalComments::Inline,
860+
},
861+
out: out_dir.path().to_path_buf(),
862+
emit_component_assets: Vec::new(),
863+
theme: Some(
864+
app_dir
865+
.path()
866+
.join("theme.json")
867+
.to_string_lossy()
868+
.to_string(),
869+
),
870+
});
871+
872+
let err = result.expect_err("missing theme token must fail");
873+
let message = format!("{err:#}");
874+
assert!(message.contains("missing-theme-token"), "msg: {message}");
875+
assert!(message.contains("--token-c"), "msg: {message}");
876+
assert!(!out_dir.path().join("protocol.bin").exists());
877+
}
878+
811879
#[test]
812880
fn test_build_custom_protocol_name() {
813881
let app_dir = create_app_dir(&[
@@ -832,6 +900,7 @@ mod tests {
832900
},
833901
out: custom_path.clone(),
834902
emit_component_assets: Vec::new(),
903+
theme: None,
835904
})
836905
.unwrap();
837906

@@ -868,6 +937,7 @@ mod tests {
868937
},
869938
out: nested.clone(),
870939
emit_component_assets: Vec::new(),
940+
theme: None,
871941
})
872942
.unwrap();
873943

0 commit comments

Comments
 (0)