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
6 changes: 6 additions & 0 deletions .github/skills/docs-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Update `docs/` in the same commit when the change is user-visible:
- Integration behavior that external developers depend on
- New features or removed features

Do not update user-facing docs for internal implementation details, regression
tests, refactors, or bug fixes that only restore already-documented behavior.
Every addition must help developers author, configure, debug, or integrate a
WebUI application. Do not add release-note-style implementation observations
to reference docs.

Keep protocol internals out of general user docs. The `docs/ai/SKILL.md` file is the single-page AI reference and should be kept in sync with all other docs.

`docs/ai/SKILL.md` is authoring-first by design. Keep deep reference material (full CLI flag tables, error-code lists, per-language integration snippets) in its canonical page and link to it from `docs/ai/SKILL.md` rather than duplicating it there.
Expand Down
19 changes: 16 additions & 3 deletions crates/webui-parser/src/component_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
//! Build-time component rendering and hydration policy.

use crate::diagnostic::{codes, Diagnostic};
use crate::html_parser::{find_comment_close, find_declaration_close, parse_tag, Attr, Tag};
use crate::html_parser::{
find_comment_close, find_declaration_close, leading_content, parse_tag, Attr, Tag,
};
use crate::{ParserError, Result};

pub(crate) const RENDER_ATTR: &str = "w-render";
Expand Down Expand Up @@ -96,8 +98,7 @@ pub(crate) fn parse_component_render_policy(
component: &str,
html: &str,
) -> Result<ComponentRenderPolicy> {
let trimmed = html.trim_start();
let source_offset = html.len() - trimmed.len();
let (trimmed, source_offset) = leading_content(html);
let Some(tag) = parse_tag(trimmed) else {
return Ok(ComponentRenderPolicy::Eager);
};
Expand Down Expand Up @@ -667,6 +668,18 @@ mod tests {
assert!(matches!(result, Ok(ComponentRenderPolicy::Eager)));
}

#[test]
fn parses_root_policy_after_leading_comment() {
let result = parse_component_render_policy(
"lazy-row",
concat!(
"<!-- Copyright (C) Corporation. All rights reserved. -->\n",
"<template w-hydrate=\"lazy\"><p>row</p></template>",
),
);
assert!(matches!(result, Ok(ComponentRenderPolicy::LazyHydration)));
}

#[test]
fn ignores_policy_text_inside_raw_text_elements() {
let result = parse_component_render_policy(
Expand Down
21 changes: 21 additions & 0 deletions crates/webui-parser/src/html_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,27 @@ impl<'a> Iterator for Walker<'a> {
}
}

/// Returns the first non-comment content after leading whitespace and HTML
/// comments, together with its byte offset in the source.
#[inline]
pub(crate) fn leading_content(source: &str) -> (&str, usize) {
let mut offset = 0usize;
loop {
let remaining = &source[offset..];
let trimmed = remaining.trim_start();
offset += remaining.len() - trimmed.len();

if !trimmed.starts_with("<!--") {
return (trimmed, offset);
}

let Some(comment_end) = find_comment_close(trimmed) else {
return (trimmed, offset);
};
offset += comment_end;
}
}

/// Return the byte index of the `>` that closes an HTML tag, ignoring quoted
/// attribute values. Returns `None` if the tag is unterminated.
#[inline]
Expand Down
34 changes: 32 additions & 2 deletions crates/webui-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3296,7 +3296,8 @@ impl HtmlParser {
adopted_specifier: Option<&str>,
mode: ComponentTemplateMode,
) -> Result<String> {
let trimmed = html.trim();
let trimmed_end = html.trim_end();
let (trimmed, _) = html::leading_content(trimmed_end);
let snippet = css_snippet.unwrap_or_default();

let processed = if trimmed.starts_with("<template") {
Expand Down Expand Up @@ -3435,7 +3436,7 @@ impl HtmlParser {
}

fn template_has_stripped_runtime_attrs(html: &str) -> bool {
let trimmed = html.trim_start();
let (trimmed, _) = html::leading_content(html);
let Some(tag) = html::parse_tag(trimmed) else {
return false;
};
Expand Down Expand Up @@ -5940,6 +5941,35 @@ mod tests {
);
}

#[test]
fn leading_comments_do_not_change_root_template_detection() {
const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
let fixtures = [
(
format!(
"{COMMENT}\n<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>"
),
"<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>",
),
(
format!("{COMMENT}\n<h1>Hello</h1>"),
"<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
),
(
format!("{COMMENT}\nHello"),
"<template shadowrootmode=\"open\">Hello</template>",
),
];

for (input, expected) in fixtures {
let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
let processed = parser
.process_component_template(&input, None, None)
.expect("leading-comment fixture should process");
assert_eq!(processed, expected);
}
}

#[test]
fn component_policy_wrapper_uses_selected_dom_strategy_and_is_build_only() {
let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
Expand Down
31 changes: 25 additions & 6 deletions crates/webui-parser/src/plugin/fast_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

use super::{AttributeAction, ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts};
use crate::component_registry::Component;
use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
use crate::html_parser::{find_element_end, find_tag_close, leading_content, opening_tag_name};
use crate::{CssLinkOptions, CssStrategy, Result};
use webui_protocol::FastElementData;

Expand Down Expand Up @@ -147,12 +147,13 @@ fn generate_f_template_from_processed(tag_name: &str, processed_template: &str)

let converted = convert_btr_to_fast(processed_template);
let trimmed = minify_inter_tag_whitespace(converted.trim());
let (trimmed, _) = leading_content(&trimmed);

if trimmed.starts_with("<template") {
output.push_str(&trimmed);
output.push_str(trimmed);
} else {
output.push_str("<template>");
output.push_str(&trimmed);
output.push_str(trimmed);
output.push_str("</template>");
}

Expand All @@ -175,6 +176,7 @@ pub fn generate_f_template_with_css_options(

let converted = convert_btr_to_fast(html_content);
let trimmed = minify_inter_tag_whitespace(converted.trim());
let (trimmed, _) = leading_content(&trimmed);

// Build the CSS injection string based on the configured strategy
let css_injection = match css_strategy {
Expand All @@ -197,7 +199,7 @@ pub fn generate_f_template_with_css_options(
};

if trimmed.starts_with("<template") {
if let Some(close_pos) = find_tag_close(&trimmed) {
if let Some(close_pos) = find_tag_close(trimmed) {
// Dev owns the wrapper — preserve attributes verbatim.
// For `CssStrategy::Module` the parser pass enforces
// `shadowrootadoptedstylesheets`, so by the time we get here
Expand All @@ -209,7 +211,7 @@ pub fn generate_f_template_with_css_options(
}
output.push_str(&trimmed[close_pos + 1..]);
} else {
output.push_str(&trimmed);
output.push_str(trimmed);
}
} else {
output.push_str("<template");
Expand All @@ -222,7 +224,7 @@ pub fn generate_f_template_with_css_options(
if let Some(ref injection) = css_injection {
output.push_str(injection);
}
output.push_str(&trimmed);
output.push_str(trimmed);
output.push_str("</template>");
}

Expand Down Expand Up @@ -806,6 +808,23 @@ mod tests {
assert!(html.contains("<span>text</span>"));
}

#[test]
fn direct_template_generation_skips_leading_comments() {
let html = generate_f_template(
"my-comp",
concat!(
"<!-- Copyright (C) Corporation. All rights reserved. -->\n",
"<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
),
None,
CssStrategy::Link,
);

assert_eq!(html.matches("<template>").count(), 1);
assert!(!html.contains("<!--"));
assert!(html.contains("<template><h1>Hello</h1></template>"));
}

#[test]
fn component_template_css_strategy_style() {
let mut plugin = FastV2ParserPlugin::new();
Expand Down
31 changes: 25 additions & 6 deletions crates/webui-parser/src/plugin/fast_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

use super::{AttributeAction, ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts};
use crate::component_registry::Component;
use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
use crate::html_parser::{find_element_end, find_tag_close, leading_content, opening_tag_name};
use crate::{CssLinkOptions, CssStrategy, Result};
use webui_protocol::FastElementData;

Expand Down Expand Up @@ -147,12 +147,13 @@ fn generate_f_template_from_processed(tag_name: &str, processed_template: &str)

let converted = convert_btr_to_fast(processed_template);
let trimmed = minify_inter_tag_whitespace(converted.trim());
let (trimmed, _) = leading_content(&trimmed);

if trimmed.starts_with("<template") {
output.push_str(&trimmed);
output.push_str(trimmed);
} else {
output.push_str("<template>");
output.push_str(&trimmed);
output.push_str(trimmed);
output.push_str("</template>");
}

Expand All @@ -175,6 +176,7 @@ pub fn generate_f_template_with_css_options(

let converted = convert_btr_to_fast(html_content);
let trimmed = minify_inter_tag_whitespace(converted.trim());
let (trimmed, _) = leading_content(&trimmed);

// Build the CSS injection string based on the configured strategy
let css_injection = match css_strategy {
Expand All @@ -197,7 +199,7 @@ pub fn generate_f_template_with_css_options(
};

if trimmed.starts_with("<template") {
if let Some(close_pos) = find_tag_close(&trimmed) {
if let Some(close_pos) = find_tag_close(trimmed) {
// Dev owns the wrapper — preserve attributes verbatim.
// For `CssStrategy::Module` the parser pass enforces
// `shadowrootadoptedstylesheets`, so by the time we get here
Expand All @@ -209,7 +211,7 @@ pub fn generate_f_template_with_css_options(
}
output.push_str(&trimmed[close_pos + 1..]);
} else {
output.push_str(&trimmed);
output.push_str(trimmed);
}
} else {
output.push_str("<template");
Expand All @@ -222,7 +224,7 @@ pub fn generate_f_template_with_css_options(
if let Some(ref injection) = css_injection {
output.push_str(injection);
}
output.push_str(&trimmed);
output.push_str(trimmed);
output.push_str("</template>");
}

Expand Down Expand Up @@ -806,6 +808,23 @@ mod tests {
assert!(html.contains("<span>text</span>"));
}

#[test]
fn direct_template_generation_skips_leading_comments() {
let html = generate_f_template(
"my-comp",
concat!(
"<!-- Copyright (C) Corporation. All rights reserved. -->\n",
"<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
),
None,
CssStrategy::Link,
);

assert_eq!(html.matches("<template>").count(), 1);
assert!(!html.contains("<!--"));
assert!(html.contains("<template><h1>Hello</h1></template>"));
}

#[test]
fn component_template_css_strategy_style() {
let mut plugin = FastV3ParserPlugin::new();
Expand Down
9 changes: 7 additions & 2 deletions crates/webui-parser/src/plugin/webui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ use crate::component_policy::{parse_component_render_policy, ComponentRenderPoli
use crate::component_registry::Component;
use crate::diagnostic::{codes, Diagnostic};
use crate::html_parser::{
find_element_end, find_tag_close, is_void_element, parse_tag, style_element_bounds,
find_element_end, find_tag_close, is_void_element, leading_content, parse_tag,
style_element_bounds,
};
use crate::{ConditionParser, DomStrategy, ParserOptions, Result};
use std::cell::Cell;
Expand Down Expand Up @@ -3286,6 +3287,7 @@ fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
/// These become "root events" (`re` array) attached to the host element
/// rather than to an element inside the shadow DOM.
fn extract_root_events(component: &str, html: &str) -> Result<Vec<EventBinding>> {
let (html, _) = leading_content(html);
if !html.starts_with("<template") {
return Ok(Vec::new());
}
Expand Down Expand Up @@ -4207,7 +4209,10 @@ mod tests {

let comp = test_component(
"test-el",
r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#,
concat!(
"<!-- Copyright (C) Corporation. All rights reserved. -->\n",
r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#,
),
Some(".root { color: red; }"),
true,
);
Expand Down
Loading