Skip to content

Commit b513efb

Browse files
feat: strip template and style comments in parser and support legal comments (#326)
1 parent 88fc307 commit b513efb

17 files changed

Lines changed: 1237 additions & 297 deletions

File tree

DESIGN.md

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,7 @@ pub struct HtmlParser {
933933
condition_parser: ConditionParser,
934934
handlebars_parser: HandlebarsParser,
935935
css_strategy: CssStrategy,
936+
legal_comments: LegalComments,
936937
// Other fields...
937938
}
938939
```
@@ -957,7 +958,28 @@ pub enum CssStrategy {
957958
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
958959
- **Module**: Registers each component's CSS as a CSS Module via an [Import Map](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps) entry whose value is a `data:text/css,...` URI. During SSR, the handler emits a `<script type="importmap">{"imports":{"component-name":"data:text/css,..."}}</script>` in each component's light DOM on first render (e.g., `<my-comp><script type="importmap">...</script><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. When the developer supplies their own `<template>` wrapper (e.g., to attach `@event` handlers), they MUST declare `shadowrootadoptedstylesheets="component-name"` on it — the parser returns `ParserError::MissingAdoptedStylesheets` at build time if the attribute is absent, so adoption can never silently fail. Multi-specifier values (`shadowrootadoptedstylesheets="component-name other-sheet"`) are honored verbatim. Components inside false `<if>` blocks or empty `<for>` loops that were not rendered during SSR get their importmap definitions emitted at `body_end`, so client-side activation can adopt them. CSS bytes are percent-encoded as needed to survive the `data:` URI parser (`%`, `#`, `"`, whitespace, and non-ASCII / control bytes); the importmap JSON object is built via `serde_json` so the specifier and URI value are correctly JSON-escaped. **Requires browser support for [Multiple Import Maps](https://github.com/WICG/import-maps/blob/main/proposals/multiple-import-maps.md) (Chrome 133+)** so each component's importmap can be emitted independently and merged into the document-level resolution table by the browser. When a CSP nonce is configured (via `RenderOptions::with_nonce` / `webui_handler_set_nonce`), the SSR-emitted `<script type="importmap">` tags include `nonce="VALUE"` (in `type`, `nonce` order) so strict `script-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, definitions for newly needed components are sent in the `templateStyles` array as `<script type="importmap">{"imports":{...}}</script>` strings (without a `nonce` attribute - the router materializes each tag client-side and applies the per-request nonce when appending 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.
959960

960-
Set at construction time with `HtmlParser::with_options(ParserOptions::try_new(...))`.
961+
Set at construction time with
962+
`HtmlParser::with_options(ParserOptions::try_new(css, dom, css_file_name_template, css_public_base, legal_comments))`.
963+
964+
#### Legal Comments
965+
```rust
966+
/// Strategy for preserving legal comments in generated output.
967+
pub enum LegalComments {
968+
/// Strip every HTML and CSS comment.
969+
None,
970+
/// Preserve legal CSS comments inline and strip all other comments.
971+
Inline,
972+
}
973+
```
974+
975+
The default is `LegalComments::Inline`, which preserves CSS comments that match
976+
esbuild's legal-comment convention: comments containing `@license` or
977+
`@preserve`, or comments starting with `/*!` or `//!`. WebUI supports only
978+
`none` and `inline` modes. HTML comments are always stripped, and bindings or
979+
directives inside HTML comments never produce fragments or plugin metadata.
980+
CSS comments are stripped from external component CSS, inline `<style>` content,
981+
component template CSS, and plugin-captured component templates unless they are
982+
legal comments and `inline` preservation is active.
961983

962984
#### Primary Method
963985
```rust
@@ -989,7 +1011,7 @@ pub trait ParserPlugin {
9891011
- **Fragment start**: `start_fragment` runs before each `HtmlParser::parse(...)` call so plugins can reset fragment-local counters
9901012
- **Attribute loop**: `classify_attribute` decides whether framework-owned attrs are kept, skipped, or skipped-and-counted as bindings
9911013
- **Element completion**: `finish_element` runs with the final binding count after all attrs are processed; returned bytes are emitted as a `Plugin` fragment
992-
- **Component registration**: `register_component_template` receives the final processed component template HTML
1014+
- **Component registration**: `register_component_template` receives the final processed component template HTML after HTML/CSS comment stripping
9931015
- **Artifact extraction**: `into_artifacts` returns post-parse outputs such as client component templates without `Any` downcasts
9941016

9951017
**Selecting parser plugins**
@@ -1043,6 +1065,7 @@ actionable message so stale dev processes can be stopped explicitly.
10431065
- Handle attributes and special elements
10441066
- Omit closing tags when the HTML parser produces no end tag (void elements, etc.)
10451067
- Handle self-closing tags (`/>` syntax) for SVG and other elements
1068+
- Strip HTML comment nodes before output; comment contents are never parsed for signals, directives, attributes, or plugin metadata
10461069

10471070
#### Buffer Management
10481071
- **Buffer Isolation:** Isolate directive content from parent context
@@ -1106,6 +1129,7 @@ impl CssParser {
11061129
- Convert dynamic variables to signals
11071130
- Handle nested variable references
11081131
- Process inline and external CSS
1132+
- Strip CSS comments during parsing, preserving only legal comments when `LegalComments::Inline` is active
11091133

11101134
### CSS Token Hoisting
11111135

@@ -1131,22 +1155,26 @@ The iterative walker visits each `call_expression` node independently, so nested
11311155
The `HtmlParser` maintains a `token_store: HashSet<String>` that accumulates tokens from two sources:
11321156

11331157
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.
1134-
2. **Inline `<style>` tags**when the parser processes a `style_element` node, it calls `extract_tokens` on the CSS content and merges the result.
1158+
2. **Inline `<style>` tags**when the parser processes a `style_element` node, it extracts token usages and definitions while stripping removable CSS comments in the same tree-sitter walk.
11351159

11361160
After parsing completes, `HtmlParser::take_tokens()` returns the sorted, deduplicated token list for inclusion in the protocol.
11371161

1138-
#### Comment-Based Signal Bindings
1162+
#### Comment Handling
11391163

1140-
HTML comments containing handlebars expressions are parsed as signal fragments:
1164+
HTML comments are stripped from parser output. Comment contents are never parsed
1165+
for signals, directives, attributes, or plugin metadata. CSS comments are
1166+
stripped from inline `<style>` elements and component CSS, except for two cases:
1167+
legal comments when `LegalComments::Inline` is active, and CSS signal fragments
1168+
in inline `<style>` elements. A CSS signal fragment is a block comment whose
1169+
trimmed body is exactly one handlebars expression:
11411170

1142-
```html
1143-
<!--{{tokens}}-->Signal { value: "tokens", raw: false }
1144-
<!--{{{tokens}}}-->Signal { value: "tokens", raw: true }
1145-
<!--{{tokens.light}}-->Signal { value: "tokens.light", raw: false }
1146-
<!-- regular comment -->Raw (preserved as-is)
1171+
```css
1172+
/*{{tokens}}*/Signal { value: "tokens", raw: false }
1173+
/*{{{tokens.light}}}*/Signal { value: "tokens.light", raw: true }
11471174
```
11481175

1149-
This mechanism is general-purpose (not limited to `tokens`) and enables comment-based placeholders for runtime value injection in HTML files. The existing handlebars parser is reused for expression parsing within comment delimiters.
1176+
Bare handlebars expressions in CSS are raw text. Dynamic CSS fragments must use
1177+
the comment wrapper so the CSS parser can distinguish them from invalid CSS.
11501178

11511179
### Design Token Resolution (`webui-tokens`)
11521180

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ pub fn build(app: &std::path::Path, out: &std::path::Path, entry: &str) -> Resul
153153
components: Vec::new(),
154154
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
155155
css_public_base: None,
156+
legal_comments: LegalComments::Inline,
156157
},
157158
out: out.to_path_buf(),
158159
})
@@ -258,6 +259,7 @@ mod tests {
258259
components: Vec::new(),
259260
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
260261
css_public_base: None,
262+
legal_comments: LegalComments::Inline,
261263
},
262264
out: out_dir.path().to_path_buf(),
263265
})
@@ -376,6 +378,7 @@ mod tests {
376378
components: vec![ext_path],
377379
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
378380
css_public_base: None,
381+
legal_comments: LegalComments::Inline,
379382
},
380383
out: out_dir.path().to_path_buf(),
381384
})
@@ -458,6 +461,7 @@ mod tests {
458461
components: vec!["test-widget".to_string()],
459462
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
460463
css_public_base: None,
464+
legal_comments: LegalComments::Inline,
461465
},
462466
out: out_dir.path().to_path_buf(),
463467
})
@@ -537,6 +541,7 @@ mod tests {
537541
components: vec!["@myui".to_string()],
538542
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
539543
css_public_base: None,
544+
legal_comments: LegalComments::Inline,
540545
},
541546
out: out_dir.path().to_path_buf(),
542547
})
@@ -585,6 +590,7 @@ mod tests {
585590
components: Vec::new(),
586591
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
587592
css_public_base: None,
593+
legal_comments: LegalComments::Inline,
588594
},
589595
out: custom_path.clone(),
590596
})
@@ -619,6 +625,7 @@ mod tests {
619625
components: Vec::new(),
620626
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
621627
css_public_base: None,
628+
legal_comments: LegalComments::Inline,
622629
},
623630
out: nested.clone(),
624631
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use clap::Args;
55
use std::path::PathBuf;
66
pub use webui::CssStrategy;
77
pub use webui::DomStrategy;
8+
pub use webui::LegalComments;
89
pub use webui::Plugin;
910
pub use webui::DEFAULT_CSS_FILE_NAME_TEMPLATE;
1011

@@ -42,6 +43,10 @@ pub struct AppArgs {
4243
/// Optional base URL/path prefix for Link-mode css hrefs
4344
#[arg(long)]
4445
pub css_public_base: Option<String>,
46+
47+
/// Legal comment handling: inline preserves legal CSS comments, none strips all comments
48+
#[arg(long, value_enum, default_value_t = LegalComments::Inline)]
49+
pub legal_comments: LegalComments,
4550
}
4651

4752
impl AppArgs {
@@ -56,6 +61,7 @@ impl AppArgs {
5661
components: self.components.clone(),
5762
css_file_name_template: self.css_file_name_template.clone(),
5863
css_public_base: self.css_public_base.clone(),
64+
legal_comments: self.legal_comments,
5965
}
6066
}
6167
}
@@ -75,6 +81,7 @@ mod tests {
7581
components: Vec::new(),
7682
css_file_name_template: "[name]-[hash].[ext]".to_string(),
7783
css_public_base: Some("https://cdn.example.com/assets".to_string()),
84+
legal_comments: LegalComments::None,
7885
};
7986
let options = args.to_build_options(std::path::Path::new("."));
8087

@@ -83,5 +90,6 @@ mod tests {
8390
options.css_public_base.as_deref(),
8491
Some("https://cdn.example.com/assets")
8592
);
93+
assert_eq!(options.legal_comments, LegalComments::None);
8694
}
8795
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,7 @@ mod tests {
11401140
components: Vec::new(),
11411141
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
11421142
css_public_base: None,
1143+
legal_comments: LegalComments::Inline,
11431144
},
11441145
app_dir: app.path().to_path_buf(),
11451146
state_file: Some(app.path().join("state.json")),
@@ -1167,6 +1168,7 @@ mod tests {
11671168
components: Vec::new(),
11681169
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
11691170
css_public_base: None,
1171+
legal_comments: LegalComments::Inline,
11701172
},
11711173
app_dir: app.path().to_path_buf(),
11721174
state_file: Some(app.path().join("state.json")),
@@ -1197,6 +1199,7 @@ mod tests {
11971199
components: Vec::new(),
11981200
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
11991201
css_public_base: None,
1202+
legal_comments: LegalComments::Inline,
12001203
},
12011204
app_dir: app.path().to_path_buf(),
12021205
state_file: Some(app.path().join("state.json")),
@@ -1232,6 +1235,7 @@ mod tests {
12321235
components: Vec::new(),
12331236
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
12341237
css_public_base: None,
1238+
legal_comments: LegalComments::Inline,
12351239
},
12361240
app_dir: app.path().to_path_buf(),
12371241
state_file: Some(app.path().join("state.json")),
@@ -1269,6 +1273,7 @@ mod tests {
12691273
components: Vec::new(),
12701274
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
12711275
css_public_base: None,
1276+
legal_comments: LegalComments::Inline,
12721277
},
12731278
app_dir: app.path().to_path_buf(),
12741279
state_file: Some(app.path().join("state.json")),
@@ -1293,6 +1298,7 @@ mod tests {
12931298
components: Vec::new(),
12941299
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
12951300
css_public_base: None,
1301+
legal_comments: LegalComments::Inline,
12961302
},
12971303
app_dir: app.path().to_path_buf(),
12981304
state_file: None,
@@ -1316,6 +1322,7 @@ mod tests {
13161322
components: Vec::new(),
13171323
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
13181324
css_public_base: None,
1325+
legal_comments: LegalComments::Inline,
13191326
},
13201327
app_dir: app.path().to_path_buf(),
13211328
state_file: Some(app.path().join("state.json")),
@@ -1482,6 +1489,7 @@ mod tests {
14821489
components: Vec::new(),
14831490
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
14841491
css_public_base: None,
1492+
legal_comments: LegalComments::Inline,
14851493
},
14861494
app_dir: app.path().to_path_buf(),
14871495
state_file: Some(app.path().join("state.json")),
@@ -1528,6 +1536,7 @@ mod tests {
15281536
components: Vec::new(),
15291537
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
15301538
css_public_base: None,
1539+
legal_comments: LegalComments::Inline,
15311540
},
15321541
app_dir,
15331542
state_file: Some(manifest_dir.join("../../examples/app/hello-world/data/state.json")),

0 commit comments

Comments
 (0)