diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a7e2ae2a..166bb5b8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -130,6 +130,7 @@ The `docs/` directory is a VitePress site for external developers consuming WebU - Any change to user-visible behavior, CLI usage, or public API **must** include a corresponding docs update in the same PR. - New features get a guide page (`docs/guide/`) or tutorial (`docs/tutorials/`). +- **User-facing docs show template syntax, state, and rendered output only.** Never expose protocol internals (fragment types, proto fields, stream IDs) in `/docs`. Protocol details belong in `DESIGN.md` and `docs/guide/advanced/protocol.md`. - Verify with `cd docs && pnpm build` when possible. --- diff --git a/DESIGN.md b/DESIGN.md index fa32e040..6d8bd166 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -347,14 +347,17 @@ pub fn into_fragment_records(self) -> WebUIFragmentRecords - Flush buffer when transitioning to non-raw content ##### Directive Processing -- **:** Extract item/collection pair and process children into separate fragment +- **:** Extract item/collection pair and process children into separate fragment. Empty `` bodies (no children) are silently skipped. - **:** Extract and parse condition, process children into separate fragment +- **:** Injects `body_start` and `body_end` raw signals around the body content - **Components:** Check component registry, process as component if found ##### Element Processing - Maintain proper tag structure - Process children recursively (iterative implementation) - Handle attributes and special elements +- Omit closing tags when the HTML parser produces no end tag (void elements, etc.) +- Handle self-closing tags (`/>` syntax) for SVG and other elements #### Buffer Management - **Buffer Isolation:** Isolate directive content from parent context diff --git a/crates/webui-parser/src/lib.rs b/crates/webui-parser/src/lib.rs index 72d690d0..95bb7ffe 100644 --- a/crates/webui-parser/src/lib.rs +++ b/crates/webui-parser/src/lib.rs @@ -234,6 +234,7 @@ impl HtmlParser { match tag_name.as_str() { "for" => return self.process_for_directive(node, source, fragments), "if" => return self.process_if_directive(node, source, fragments), + "body" => return self.process_body_element(node, source, fragments), _ => { if self.component_registry.contains(tag_name.as_str()) { return self.process_component_directive( @@ -399,6 +400,15 @@ impl HtmlParser { value.contains("{{") } + /// Check if an element node has an end_tag child. + fn has_end_tag(&self, node: Node) -> bool { + let mut cursor = node.walk(); + let result = node + .named_children(&mut cursor) + .any(|child| child.kind() == "end_tag"); + result + } + /// Process a regular HTML element with attribute-aware parsing. fn process_regular_element( &mut self, @@ -411,6 +421,7 @@ impl HtmlParser { let is_self_closing = tag_node .map(|n| n.kind() == "self_closing_tag") .unwrap_or(false); + let has_end = self.has_end_tag(node); if let Some(tag_node) = tag_node { // Emit ""); } + } else if !has_end { + // Void element (no end tag from parser) — just close the opening tag + self.add_raw_fragment(">"); } else { - // Emit ">" self.add_raw_fragment(">"); // Process children (skip start_tag and end_tag nodes) @@ -594,6 +607,28 @@ impl HtmlParser { Ok(()) } + /// Process a `` element, injecting body_start/body_end signals. + fn process_body_element( + &mut self, + node: Node, + source: &str, + fragments: &mut Vec, + ) -> Result<()> { + self.add_raw_fragment(""); + self.flush_raw_buffer(fragments); + fragments.push(WebUIFragment::signal("body_start", true)); + for child in node.named_children(&mut node.walk()) { + let kind = child.kind(); + if kind != "start_tag" && kind != "end_tag" { + self.process_child_node(child, source, fragments)?; + } + } + self.flush_raw_buffer(fragments); + fragments.push(WebUIFragment::signal("body_end", true)); + self.add_raw_fragment(""); + Ok(()) + } + /// Process a directive. fn process_for_directive( &mut self, @@ -653,6 +688,11 @@ impl HtmlParser { // Restore the original buffer std::mem::swap(&mut self.raw_buffer, &mut temp_buffer); + // Skip the for fragment entirely if the body is empty + if for_fragment.is_empty() { + return Ok(()); + } + // Store the record self.fragment_records.insert( fragment_id.clone(), @@ -1559,4 +1599,201 @@ mod tests { matches!(attr_stream.fragments[1].fragment.as_ref(), Some(Fragment::Signal(signal)) if signal.value == "world") ); } + + // ── Body signal tests ───────────────────────────────────────────── + + #[test] + fn test_body_signals() { + let (fragments, _) = parse_and_get_fragments(""); + assert_eq!(fragments.len(), 5); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + assert!( + matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(signal)) if signal.value == "body_start" && signal.raw) + ); + assert!( + matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + assert!( + matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(signal)) if signal.value == "body_end" && signal.raw) + ); + assert!( + matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + } + + // ── Empty for handling tests ────────────────────────────────────── + + #[test] + fn test_empty_for_produces_nothing() { + let (fragments, records) = + parse_and_get_fragments(r#"
"#); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "
") + ); + assert!(!records.contains_key("for-1")); + } + + // ── Self-closing / void element tests ───────────────────────────── + + #[test] + fn test_self_closing_svg_path() { + let (fragments, _) = + parse_and_get_fragments(r#""#); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#""#) + ); + } + + #[test] + fn test_html5_void_elements() { + let (fragments, _) = parse_and_get_fragments( + r#"
test

"#, + ); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#"
test

"#) + ); + } + + #[test] + fn test_self_closing_with_dynamic_attributes() { + let (fragments, _) = + parse_and_get_fragments(r#"{{imageAlt}}"#); + assert_eq!(fragments.len(), 4); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + } + + #[test] + fn test_self_closing_with_boolean_attributes() { + let (fragments, _) = parse_and_get_fragments( + r#""#, + ); + assert_eq!(fragments.len(), 4); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + } + + #[test] + fn test_multiple_self_closing_in_sequence() { + let (fragments, _) = + parse_and_get_fragments(r#"
"#); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#"
"#) + ); + } + + #[test] + fn test_self_closing_with_mixed_content() { + let (fragments, _) = + parse_and_get_fragments(r#"
Text beforeText after
"#); + assert_eq!(fragments.len(), 3); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "
Text beforeText after
") + ); + } + + #[test] + fn test_self_closing_svg_elements() { + let (fragments, _) = parse_and_get_fragments( + r#""#, + ); + assert_eq!(fragments.len(), 4); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == ""#) + ); + } + + #[test] + fn test_self_closing_inside_for_loop() { + let (fragments, records) = parse_and_get_fragments( + r#""#, + ); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.item == "item" && fl.collection == "items" && fl.fragment_id == "for-1") + ); + let for_stream = records.get("for-1").expect("Missing for-1"); + assert_eq!(for_stream.fragments.len(), 3); + assert!( + matches!(for_stream.fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "") + ); + } + + #[test] + fn test_self_closing_whitespace_variations() { + let (fragments, _) = + parse_and_get_fragments(r#"
"#); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#"
"#) + ); + } + + #[test] + fn test_deeply_nested_self_closing() { + let (fragments, _) = parse_and_get_fragments( + r#"

"#, + ); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#"

"#) + ); + } + + #[test] + fn test_self_closing_vs_empty_regular_tags() { + let (fragments, _) = + parse_and_get_fragments(r#"
"#); + assert_eq!(fragments.len(), 1); + assert!( + matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == r#"
"#) + ); + } } diff --git a/docs/guide/concepts/directives/attributes.md b/docs/guide/concepts/directives/attributes.md index 994ac909..0bfff335 100644 --- a/docs/guide/concepts/directives/attributes.md +++ b/docs/guide/concepts/directives/attributes.md @@ -1,24 +1,16 @@ # Attribute Directives -WebUI provides several ways to bind dynamic data to HTML attributes. When an attribute value contains handlebars expressions, the parser emits attribute fragments that are resolved at render time. +WebUI provides several ways to bind dynamic data to HTML attributes. When an attribute value contains handlebars expressions, the value is resolved from state at render time. ## Simple Dynamic Attributes -Use {{}} inside an attribute value to bind it to a state signal: +Use {{}} inside an attribute value to bind it to a state value: ```html {{linkText}} {{imageAlt}} ``` -When the entire attribute value is a single handlebars expression, WebUI produces a simple attribute binding: - -```json -{ "type": "attribute", "name": "href", "value": "url" } -``` - -At render time, the signal name is resolved against the state to produce the final attribute value. - ### Example State: @@ -100,35 +92,15 @@ Prefix an attribute name with `:` to create a complex binding. This is used for ``` -Complex attributes preserve the `:` prefix in the attribute name and are marked with `complex: true` in the protocol: +## Mixed Attributes -```json -{ "type": "attribute", "name": ":config", "value": "settings", "complex": true } -``` - -## Mixed (Template) Attributes - -When an attribute value contains a mix of static text and dynamic expressions, WebUI creates a template sub-stream: +When an attribute value contains a mix of static text and dynamic expressions, each part is resolved independently: ```html ``` -The parser splits the value into a separate fragment stream referenced by a template ID: - -```json -{ "type": "attribute", "name": "value", "template": "attr-1" } -``` - -The sub-stream `attr-1` contains: -```json -[ - { "type": "raw", "value": "hello " }, - { "type": "signal", "value": "world" } -] -``` - ### Example State: @@ -145,7 +117,7 @@ Output: ## Combining Attribute Types -You can mix static attributes, dynamic attributes, boolean attributes, and complex attributes on the same element: +You can mix static, dynamic, boolean, and complex attributes on the same element: ```html ``` -Static attributes (like `id="comp"`) are passed through as raw HTML. Dynamic, boolean, and complex attributes each produce their own attribute fragment in the protocol. - -## Protocol Output - -The attribute fragment type supports all binding styles through a single flexible structure: - -| Attribute Style | Protocol Fields | -|----------------|----------------| -| Simple dynamic | `name`, `value` | -| Boolean (`?`) | `name`, `conditionTree` | -| Complex (`:`) | `name`, `value`, `complex: true` | -| Mixed/template | `name`, `template` (references sub-stream) | +Static attributes (like `id="comp"`) are passed through as-is. Dynamic, boolean, and complex attributes are resolved from state at render time. diff --git a/docs/guide/concepts/directives/for.md b/docs/guide/concepts/directives/for.md index acde611f..c50b6c1a 100644 --- a/docs/guide/concepts/directives/for.md +++ b/docs/guide/concepts/directives/for.md @@ -60,10 +60,4 @@ Where: - The item variable is only available within the loop body - You can access nested properties of the item using dot notation - If the collection doesn't exist or isn't an array, an error will be raised during rendering - -## Protocol Output - -When the parser processes a `` directive, it generates a `WebUIFragmentFor` in the protocol with the following properties: -- `item`: The name of the loop variable -- `collection`: The name of the array to iterate over -- `fragmentId`: A reference to the content template for each iteration +- **Empty `` bodies** (with no children) are silently skipped — no output is generated diff --git a/docs/guide/concepts/directives/if.md b/docs/guide/concepts/directives/if.md index 22107b12..fd67d86d 100644 --- a/docs/guide/concepts/directives/if.md +++ b/docs/guide/concepts/directives/if.md @@ -66,20 +66,3 @@ Supported logical operators: - You cannot mix different types of logical operators (`&&` and `||`) in the same condition - Parentheses for grouping are not supported - The condition is evaluated against the current state object - -## Protocol Output - -When the WebUI parser processes an `` directive, it generates a protocol entry like this: - -```json -{ - "type": "if", - "condition": { - "kind": "identifier", - "value": "isLoggedIn" - }, - "fragmentId": "if-1" -} -``` - -The content inside the `` directive is stored in a separate fragment with the ID specified in `fragmentId`. diff --git a/docs/guide/concepts/directives/signals.md b/docs/guide/concepts/directives/signals.md index 3ad0c712..e8983aef 100644 --- a/docs/guide/concepts/directives/signals.md +++ b/docs/guide/concepts/directives/signals.md @@ -71,24 +71,29 @@ Example:

There are {{items.length}} items in the list.

``` -## Protocol Output +## Body Signals -When the WebUI parser processes signal directives, it generates protocol entries like these: +WebUI automatically injects two special signals around `` content: -For {{{}}} (escaped): -```json -{ - "type": "signal", - "value": "user.name", - "raw": false -} +- **`body_start`** — injected immediately after the `` opening tag +- **`body_end`** — injected immediately before the `` closing tag + +These enable handlers to inject content at the start and end of the document body (e.g., hydration scripts, analytics tags). + +### Example + +Template: +```html + + + ``` -For {{{}}} (raw): -```json -{ - "type": "signal", - "value": "rawHtmlContent", - "raw": true -} -``` \ No newline at end of file +Output (with handler-injected content): +```html + + + + + +```