Skip to content

Commit 247542e

Browse files
fix: preserve raw style text in compiled templates
Treat style element contents as raw text during WebUI compiled-template normalization while only accepting compiler-generated text markers at tracked offsets. This prevents legal CSS comments containing HTML-like tags or marker-like text from corrupting the client template registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f40666 commit 247542e

1 file changed

Lines changed: 168 additions & 14 deletions

File tree

crates/webui-parser/src/plugin/webui.rs

Lines changed: 168 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ struct TemplateSectionMeta {
234234
/// These are resolved into `text_runs` during finalization.
235235
/// Each entry is `(path, raw)` where raw indicates triple-brace `{{{...}}}`.
236236
text_bindings: Vec<(String, bool)>,
237+
/// Byte offsets in `html` where compiler-generated text markers start.
238+
/// Authored CSS may contain marker-like text; only these offsets are metadata.
239+
text_marker_offsets: Vec<usize>,
237240
/// Client text-run metadata: `(slot, parts, raw)`.
238241
/// `raw` is true when the binding uses triple-brace `{{{...}}}` syntax.
239242
text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
@@ -856,6 +859,7 @@ fn compile_section(input: &str, blocks: &mut Vec<TemplateSectionMeta>) -> Templa
856859
let mut meta = TemplateSectionMeta {
857860
html: String::with_capacity(input.len()),
858861
text_bindings: Vec::new(),
862+
text_marker_offsets: Vec::new(),
859863
text_runs: Vec::new(),
860864
attr_bindings: Vec::new(),
861865
attr_groups: Vec::new(),
@@ -988,7 +992,7 @@ fn compile_text_binding_at(
988992
let expr = input[index + 3..end].trim();
989993
let idx = meta.text_bindings.len();
990994
meta.text_bindings.push((expr.to_string(), true));
991-
let _ = write!(meta.html, "<!--t:{idx}-->");
995+
emit_text_marker(meta, idx);
992996
return Some(end + 3);
993997
}
994998
}
@@ -998,14 +1002,21 @@ fn compile_text_binding_at(
9981002
let expr = input[index + 2..end].trim();
9991003
let idx = meta.text_bindings.len();
10001004
meta.text_bindings.push((expr.to_string(), false));
1001-
let _ = write!(meta.html, "<!--t:{idx}-->");
1005+
emit_text_marker(meta, idx);
10021006
return Some(end + 2);
10031007
}
10041008
}
10051009

10061010
None
10071011
}
10081012

1013+
fn emit_text_marker(meta: &mut TemplateSectionMeta, index: usize) {
1014+
meta.text_marker_offsets.push(meta.html.len());
1015+
meta.html.push_str("<!--t:");
1016+
let _ = write!(meta.html, "{index}");
1017+
meta.html.push_str("-->");
1018+
}
1019+
10091020
fn compile_style_content(input: &str, meta: &mut TemplateSectionMeta) {
10101021
let bytes = input.as_bytes();
10111022
let len = bytes.len();
@@ -1055,7 +1066,7 @@ fn compile_css_signal_comment(comment: &str, meta: &mut TemplateSectionMeta) ->
10551066
if let Some(signal) = comment_policy::parse_css_signal_comment(comment) {
10561067
let idx = meta.text_bindings.len();
10571068
meta.text_bindings.push((signal.path, signal.raw));
1058-
let _ = write!(meta.html, "<!--t:{idx}-->");
1069+
emit_text_marker(meta, idx);
10591070
return true;
10601071
}
10611072

@@ -1116,6 +1127,7 @@ fn ascii_starts_with_ignore_case(input: &[u8], prefix: &[u8]) -> bool {
11161127
enum FragmentNode {
11171128
Element(FragmentElement),
11181129
Text(String),
1130+
TextMarker(usize),
11191131
Comment(String),
11201132
}
11211133

@@ -1135,7 +1147,8 @@ struct FragmentAttr {
11351147

11361148
fn finalize_template_section(meta: &mut TemplateSectionMeta) {
11371149
let raw_html = std::mem::take(&mut meta.html);
1138-
let nodes = parse_fragment_nodes(&raw_html);
1150+
let text_marker_offsets = std::mem::take(&mut meta.text_marker_offsets);
1151+
let nodes = parse_fragment_nodes(&raw_html, &text_marker_offsets);
11391152
let text_bindings = meta.text_bindings.clone();
11401153
let mut finalized_html = String::with_capacity(raw_html.len());
11411154
let mut text_runs = Vec::new();
@@ -1233,6 +1246,7 @@ fn process_fragment_children(
12331246
}
12341247

12351248
match &nodes[index] {
1249+
FragmentNode::TextMarker(_) => {}
12361250
FragmentNode::Comment(data) => {
12371251
if let Some(idx) = parse_marker_index(data, "c:") {
12381252
if let Some(slot) = condition_slots.get_mut(idx) {
@@ -1377,14 +1391,13 @@ fn collect_text_run(
13771391
}
13781392
consumed += 1;
13791393
}
1380-
FragmentNode::Comment(data) => {
1381-
if let Some(index) = parse_marker_index(data, "t:") {
1382-
if let Some((path, raw)) = text_bindings.get(index) {
1383-
parts.push(CompiledAttrPart::Dynamic(path.clone()));
1384-
has_dynamic = true;
1385-
if *raw {
1386-
is_raw = true;
1387-
}
1394+
FragmentNode::Comment(_) => break,
1395+
FragmentNode::TextMarker(index) => {
1396+
if let Some((path, raw)) = text_bindings.get(*index) {
1397+
parts.push(CompiledAttrPart::Dynamic(path.clone()));
1398+
has_dynamic = true;
1399+
if *raw {
1400+
is_raw = true;
13881401
}
13891402
consumed += 1;
13901403
} else {
@@ -1442,7 +1455,7 @@ fn emit_html_attr_value(value: &str, out: &mut String) {
14421455
}
14431456
}
14441457

1445-
fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
1458+
fn parse_fragment_nodes(input: &str, text_marker_offsets: &[usize]) -> Vec<FragmentNode> {
14461459
let mut roots = Vec::new();
14471460
let mut stack: Vec<FragmentElement> = Vec::new();
14481461
let bytes = input.as_bytes();
@@ -1451,6 +1464,18 @@ fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
14511464

14521465
while index < len {
14531466
let remaining = &input[index..];
1467+
if let Some((marker_index, marker_end)) =
1468+
find_text_marker_comment(input, index, 0, text_marker_offsets)
1469+
{
1470+
push_fragment_node(
1471+
&mut roots,
1472+
&mut stack,
1473+
FragmentNode::TextMarker(marker_index),
1474+
);
1475+
index = marker_end;
1476+
continue;
1477+
}
1478+
14541479
if remaining.starts_with("<!--") {
14551480
if let Some(close) = remaining.find("-->") {
14561481
push_fragment_node(
@@ -1474,9 +1499,33 @@ fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
14741499
}
14751500

14761501
if remaining.starts_with('<') {
1477-
if let Some((element, consumed)) = parse_fragment_start_tag(remaining) {
1502+
if let Some((mut element, consumed)) = parse_fragment_start_tag(remaining) {
14781503
if element.self_closing {
14791504
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1505+
} else if element.tag_name.eq_ignore_ascii_case("style") {
1506+
let content_start = index + consumed;
1507+
if let Some((close_start, close_end)) = find_style_end_tag(input, content_start)
1508+
{
1509+
push_style_raw_text_nodes(
1510+
&mut element.children,
1511+
&input[content_start..close_start],
1512+
content_start,
1513+
text_marker_offsets,
1514+
);
1515+
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1516+
index = close_end;
1517+
continue;
1518+
}
1519+
1520+
push_style_raw_text_nodes(
1521+
&mut element.children,
1522+
&input[content_start..],
1523+
content_start,
1524+
text_marker_offsets,
1525+
);
1526+
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1527+
index = len;
1528+
continue;
14801529
} else {
14811530
stack.push(element);
14821531
}
@@ -1500,6 +1549,70 @@ fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
15001549
roots
15011550
}
15021551

1552+
fn push_style_raw_text_nodes(
1553+
children: &mut Vec<FragmentNode>,
1554+
input: &str,
1555+
base_offset: usize,
1556+
text_marker_offsets: &[usize],
1557+
) {
1558+
let bytes = input.as_bytes();
1559+
let len = bytes.len();
1560+
let mut index = 0usize;
1561+
let mut text_start = 0usize;
1562+
1563+
while index + 10 <= len {
1564+
if let Some((marker_index, marker_end)) =
1565+
find_text_marker_comment(input, index, base_offset, text_marker_offsets)
1566+
{
1567+
if text_start < index {
1568+
children.push(FragmentNode::Text(input[text_start..index].to_string()));
1569+
}
1570+
children.push(FragmentNode::TextMarker(marker_index));
1571+
index = marker_end;
1572+
text_start = marker_end;
1573+
continue;
1574+
}
1575+
index += 1;
1576+
}
1577+
1578+
if text_start < len {
1579+
children.push(FragmentNode::Text(input[text_start..].to_string()));
1580+
}
1581+
}
1582+
1583+
fn find_text_marker_comment(
1584+
input: &str,
1585+
index: usize,
1586+
base_offset: usize,
1587+
text_marker_offsets: &[usize],
1588+
) -> Option<(usize, usize)> {
1589+
let bytes = input.as_bytes();
1590+
if bytes.get(index..index + 6) != Some(b"<!--t:") {
1591+
return None;
1592+
}
1593+
if text_marker_offsets
1594+
.binary_search(&(base_offset + index))
1595+
.is_err()
1596+
{
1597+
return None;
1598+
}
1599+
1600+
let mut cursor = index + 6;
1601+
let digit_start = cursor;
1602+
let mut marker_index = 0usize;
1603+
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
1604+
marker_index = marker_index
1605+
.checked_mul(10)?
1606+
.checked_add((bytes[cursor] - b'0') as usize)?;
1607+
cursor += 1;
1608+
}
1609+
if cursor == digit_start || bytes.get(cursor..cursor + 3) != Some(b"-->") {
1610+
return None;
1611+
}
1612+
1613+
Some((marker_index, cursor + 3))
1614+
}
1615+
15031616
fn push_fragment_node(
15041617
roots: &mut Vec<FragmentNode>,
15051618
stack: &mut [FragmentElement],
@@ -2512,6 +2625,47 @@ mod tests {
25122625
assert!(!result.contains("*/"));
25132626
}
25142627

2628+
#[test]
2629+
fn test_metadata_keeps_legal_style_comment_with_html_like_tag_as_raw_text() {
2630+
let result = generate_compiled_template(
2631+
"my-component",
2632+
r#"<style>:host { display: block; }/*! @license The <my-component> element. */.container { padding: 16px; }</style><div>hello</div>"#,
2633+
);
2634+
2635+
assert_no_client_markers(&result);
2636+
assert!(result.contains("@license The <my-component> element."));
2637+
assert!(!result.contains("</my-component>"));
2638+
assert!(result.contains("</style><div>hello</div>"));
2639+
}
2640+
2641+
#[test]
2642+
fn test_metadata_keeps_marker_like_text_in_legal_style_comment_literal() {
2643+
let result = generate_compiled_template(
2644+
"my-component",
2645+
r#"<p>{{title}}</p><style>/*! @license <!--t:0--> */.x { color: red; }</style>"#,
2646+
);
2647+
2648+
assert!(result.contains("<!--t:0-->"));
2649+
assert!(result
2650+
.contains(r#"h:"<p></p><style>/*! @license <!--t:0--> */.x { color: red; }</style>""#));
2651+
assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2652+
assert!(!result.contains(r#"[[[1],0],[["title"]]]"#));
2653+
}
2654+
2655+
#[test]
2656+
fn test_metadata_keeps_marker_like_style_text_between_real_signal_markers() {
2657+
let result = generate_compiled_template(
2658+
"my-component",
2659+
r#"<style>/*{{first}}*/📚/*! @license <!--t:0--> <my-component> *//*{{{second}}}*/</style><div>done</div>"#,
2660+
);
2661+
2662+
assert!(result.contains(r#"["first"]"#));
2663+
assert!(result.contains(r#"["second"]"#));
2664+
assert!(result.contains("📚/*! @license <!--t:0--> <my-component> */"));
2665+
assert!(!result.contains("</my-component>"));
2666+
assert!(result.contains("</style><div>done</div>"));
2667+
}
2668+
25152669
#[test]
25162670
fn test_metadata_strips_style_line_comments_without_processing_bindings() {
25172671
let result = generate_compiled_template(

0 commit comments

Comments
 (0)