tstring-html-bindings 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (16) hide show
  1. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/Cargo.lock +5 -5
  2. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/Cargo.toml +1 -1
  3. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/PKG-INFO +1 -1
  4. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/pyproject.toml +1 -1
  5. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-bindings/Cargo.toml +2 -2
  6. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-bindings/src/lib.rs +77 -1
  7. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-rs/Cargo.toml +1 -1
  8. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-rs/src/lib.rs +131 -42
  9. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-thtml-rs/Cargo.toml +1 -1
  10. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-thtml-rs/src/lib.rs +53 -20
  11. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/README.md +0 -0
  12. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/python/tstring_html_bindings/__init__.py +0 -0
  13. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-format-doc-rs/Cargo.toml +0 -0
  14. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-format-doc-rs/src/lib.rs +0 -0
  15. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-bindings/README.md +0 -0
  16. {tstring_html_bindings-0.1.4 → tstring_html_bindings-0.1.6}/tstring-html-rs/src/formatter.rs +0 -0
@@ -290,14 +290,14 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
290
290
 
291
291
  [[package]]
292
292
  name = "tstring-format-doc"
293
- version = "0.1.4"
293
+ version = "0.1.6"
294
294
  dependencies = [
295
295
  "unicode-width",
296
296
  ]
297
297
 
298
298
  [[package]]
299
299
  name = "tstring-html"
300
- version = "0.1.4"
300
+ version = "0.1.6"
301
301
  dependencies = [
302
302
  "tstring-format-doc",
303
303
  "tstring-syntax",
@@ -305,7 +305,7 @@ dependencies = [
305
305
 
306
306
  [[package]]
307
307
  name = "tstring-html-backend-e2e-tests"
308
- version = "0.1.4"
308
+ version = "0.1.6"
309
309
  dependencies = [
310
310
  "serde",
311
311
  "toml",
@@ -316,7 +316,7 @@ dependencies = [
316
316
 
317
317
  [[package]]
318
318
  name = "tstring-html-bindings"
319
- version = "0.1.4"
319
+ version = "0.1.6"
320
320
  dependencies = [
321
321
  "pyo3",
322
322
  "tstring-html",
@@ -335,7 +335,7 @@ dependencies = [
335
335
 
336
336
  [[package]]
337
337
  name = "tstring-thtml"
338
- version = "0.1.4"
338
+ version = "0.1.6"
339
339
  dependencies = [
340
340
  "tstring-html",
341
341
  "tstring-syntax",
@@ -9,7 +9,7 @@ homepage = "https://github.com/koxudaxi/tstring-html"
9
9
  license = "MIT"
10
10
  repository = "https://github.com/koxudaxi/tstring-html"
11
11
  rust-version = "1.94.0"
12
- version = "0.1.4"
12
+ version = "0.1.6"
13
13
 
14
14
  [workspace.dependencies]
15
15
  pyo3 = "0.27.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tstring-html-bindings
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tstring-html-bindings"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Native Python bindings for tstring-html"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -19,8 +19,8 @@ extension-module = ["pyo3/extension-module"]
19
19
 
20
20
  [dependencies]
21
21
  pyo3 = { workspace = true, features = ["abi3-py314"] }
22
- tstring-html = { version = "0.1.4", path = "../tstring-html-rs" }
23
- tstring-thtml = { version = "0.1.4", path = "../tstring-thtml-rs" }
22
+ tstring-html = { version = "0.1.6", path = "../tstring-html-rs" }
23
+ tstring-thtml = { version = "0.1.6", path = "../tstring-thtml-rs" }
24
24
  tstring-syntax.workspace = true
25
25
 
26
26
  [dev-dependencies]
@@ -607,6 +607,17 @@ fn render_thtml_node(
607
607
  for child in &element.children {
608
608
  match child {
609
609
  Node::Text(text) => out.push_str(&text.value),
610
+ Node::Interpolation(interpolation)
611
+ if element.name.eq_ignore_ascii_case("title") =>
612
+ {
613
+ let Some(value) = context.values.get(interpolation.interpolation_index)
614
+ else {
615
+ return Err(runtime_error_to_py(
616
+ "Missing runtime value for interpolation.",
617
+ ));
618
+ };
619
+ render_escaped_text_value(value, out).map_err(backend_error_to_py)?;
620
+ }
610
621
  _ => {
611
622
  return Err(runtime_error_to_py(
612
623
  "Invalid raw-text content in T-HTML render path.",
@@ -646,6 +657,43 @@ fn render_thtml_node(
646
657
  Ok(())
647
658
  }
648
659
 
660
+ fn render_escaped_text_value(value: &RuntimeValue, out: &mut String) -> Result<(), BackendError> {
661
+ match value {
662
+ RuntimeValue::Null => {}
663
+ RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
664
+ RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
665
+ RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
666
+ RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
667
+ RuntimeValue::RawHtml(value) => out.push_str(&escape_html_text(value)),
668
+ RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
669
+ for value in values {
670
+ render_escaped_text_value(value, out)?;
671
+ }
672
+ }
673
+ RuntimeValue::Attributes(_) => {
674
+ return Err(tstring_html::runtime_error(
675
+ "html.runtime.child_type",
676
+ "Mapping-like values cannot be rendered as children.",
677
+ None,
678
+ ));
679
+ }
680
+ }
681
+ Ok(())
682
+ }
683
+
684
+ fn escape_html_text(value: &str) -> String {
685
+ let mut escaped = String::with_capacity(value.len());
686
+ for ch in value.chars() {
687
+ match ch {
688
+ '&' => escaped.push_str("&amp;"),
689
+ '<' => escaped.push_str("&lt;"),
690
+ '>' => escaped.push_str("&gt;"),
691
+ _ => escaped.push(ch),
692
+ }
693
+ }
694
+ escaped
695
+ }
696
+
649
697
  fn render_attribute_value_for_component(
650
698
  py: Python<'_>,
651
699
  value: &tstring_html::AttributeValue,
@@ -883,7 +931,9 @@ fn tstring_html_bindings(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResu
883
931
 
884
932
  #[cfg(test)]
885
933
  mod tests {
886
- use super::ParseCache;
934
+ use super::{ParseCache, render_thtml_node};
935
+ use pyo3::Python;
936
+ use tstring_html::{Node, RuntimeContext, RuntimeValue};
887
937
 
888
938
  #[test]
889
939
  fn parse_cache_reuses_entries() {
@@ -974,4 +1024,30 @@ mod tests {
974
1024
  assert_eq!(*value, 7);
975
1025
  assert_eq!(attempts, 2);
976
1026
  }
1027
+
1028
+ #[test]
1029
+ fn render_thtml_node_treats_uppercase_title_as_escaped_text() {
1030
+ Python::attach(|py| {
1031
+ let node = Node::RawTextElement(tstring_html::RawTextElementNode {
1032
+ name: "TITLE".to_string(),
1033
+ attributes: Vec::new(),
1034
+ children: vec![Node::Interpolation(tstring_html::InterpolationNode {
1035
+ interpolation_index: 0,
1036
+ expression: "title".to_string(),
1037
+ raw_source: Some("{title}".to_string()),
1038
+ conversion: None,
1039
+ format_spec: String::new(),
1040
+ span: None,
1041
+ })],
1042
+ span: None,
1043
+ });
1044
+ let context = RuntimeContext {
1045
+ values: vec![RuntimeValue::RawHtml("<safe>".to_string())],
1046
+ };
1047
+ let mut out = String::new();
1048
+ render_thtml_node(py, &node, &context, None, None, &mut out)
1049
+ .expect("render uppercase title");
1050
+ assert_eq!(out, "<TITLE>&lt;safe&gt;</TITLE>");
1051
+ });
1052
+ }
977
1053
  }
@@ -9,5 +9,5 @@ rust-version.workspace = true
9
9
  version.workspace = true
10
10
 
11
11
  [dependencies]
12
- tstring-format-doc = { version = "0.1.0", path = "../tstring-format-doc-rs" }
12
+ tstring-format-doc = { version = "0.1.5", path = "../tstring-format-doc-rs" }
13
13
  tstring-syntax.workspace = true
@@ -829,6 +829,10 @@ pub fn is_raw_text_tag(name: &str) -> bool {
829
829
  matches!(name, "script" | "style" | "title" | "textarea")
830
830
  }
831
831
 
832
+ fn raw_text_allows_interpolation(name: &str) -> bool {
833
+ name.eq_ignore_ascii_case("title")
834
+ }
835
+
832
836
  fn validate_html_document(document: &Document) -> BackendResult<()> {
833
837
  for child in &document.children {
834
838
  validate_html_node(child)?;
@@ -913,32 +917,45 @@ fn validate_html_node(node: &Node) -> BackendResult<()> {
913
917
  }
914
918
  Node::RawTextElement(element) => {
915
919
  validate_attributes(&element.attributes)?;
916
- for child in &element.children {
917
- match child {
918
- Node::Interpolation(interpolation) => {
919
- return Err(semantic_error(
920
- "html.semantic.raw_text_interpolation",
921
- format!("Interpolations are not allowed inside <{}>.", element.name),
922
- interpolation.span.clone(),
923
- ));
924
- }
925
- Node::Text(_) => {}
926
- _ => {
927
- return Err(semantic_error(
928
- "html.semantic.raw_text_content",
929
- format!("Only text is allowed inside <{}>.", element.name),
930
- element.span.clone(),
931
- ));
932
- }
933
- }
934
- }
935
- Ok(())
920
+ validate_raw_text_children(element)
936
921
  }
937
922
  Node::Fragment(fragment) => validate_children(&fragment.children),
938
923
  _ => Ok(()),
939
924
  }
940
925
  }
941
926
 
927
+ fn validate_raw_text_children(element: &RawTextElementNode) -> BackendResult<()> {
928
+ for child in &element.children {
929
+ match child {
930
+ Node::Text(_) => {}
931
+ Node::Interpolation(_) if raw_text_allows_interpolation(&element.name) => {}
932
+ Node::Interpolation(interpolation) => {
933
+ return Err(semantic_error(
934
+ "html.semantic.raw_text_interpolation",
935
+ format!("Interpolations are not allowed inside <{}>.", element.name),
936
+ interpolation.span.clone(),
937
+ ));
938
+ }
939
+ _ => {
940
+ let message = if raw_text_allows_interpolation(&element.name) {
941
+ format!(
942
+ "Only text and interpolations are allowed inside <{}>.",
943
+ element.name
944
+ )
945
+ } else {
946
+ format!("Only text is allowed inside <{}>.", element.name)
947
+ };
948
+ return Err(semantic_error(
949
+ "html.semantic.raw_text_content",
950
+ message,
951
+ element.span.clone(),
952
+ ));
953
+ }
954
+ }
955
+ }
956
+ Ok(())
957
+ }
958
+
942
959
  fn validate_children(children: &[Node]) -> BackendResult<()> {
943
960
  for child in children {
944
961
  validate_html_node(child)?;
@@ -1030,28 +1047,7 @@ fn render_node(node: &Node, context: &RuntimeContext, out: &mut String) -> Backe
1030
1047
  context,
1031
1048
  out,
1032
1049
  )?,
1033
- Node::RawTextElement(element) => {
1034
- out.push('<');
1035
- out.push_str(&element.name);
1036
- let normalized = normalize_attributes(&element.attributes, context)?;
1037
- write_attributes(&normalized, out);
1038
- out.push('>');
1039
- for child in &element.children {
1040
- match child {
1041
- Node::Text(text) => out.push_str(&text.value),
1042
- _ => {
1043
- return Err(semantic_error(
1044
- "html.semantic.raw_text_render",
1045
- format!("Only text can be rendered inside <{}>.", element.name),
1046
- element.span.clone(),
1047
- ));
1048
- }
1049
- }
1050
- }
1051
- out.push_str("</");
1052
- out.push_str(&element.name);
1053
- out.push('>');
1054
- }
1050
+ Node::RawTextElement(element) => render_raw_text_element(element, context, out)?,
1055
1051
  Node::ComponentTag(component) => {
1056
1052
  return Err(semantic_error(
1057
1053
  "html.semantic.component_render",
@@ -1066,6 +1062,45 @@ fn render_node(node: &Node, context: &RuntimeContext, out: &mut String) -> Backe
1066
1062
  Ok(())
1067
1063
  }
1068
1064
 
1065
+ fn render_raw_text_element(
1066
+ element: &RawTextElementNode,
1067
+ context: &RuntimeContext,
1068
+ out: &mut String,
1069
+ ) -> BackendResult<()> {
1070
+ out.push('<');
1071
+ out.push_str(&element.name);
1072
+ let normalized = normalize_attributes(&element.attributes, context)?;
1073
+ write_attributes(&normalized, out);
1074
+ out.push('>');
1075
+ for child in &element.children {
1076
+ match child {
1077
+ Node::Text(text) => out.push_str(&text.value),
1078
+ Node::Interpolation(interpolation) if raw_text_allows_interpolation(&element.name) => {
1079
+ render_escaped_text_value(value_for_interpolation(context, interpolation)?, out)?;
1080
+ }
1081
+ _ => {
1082
+ let message = if raw_text_allows_interpolation(&element.name) {
1083
+ format!(
1084
+ "Only text and interpolations can be rendered inside <{}>.",
1085
+ element.name
1086
+ )
1087
+ } else {
1088
+ format!("Only text can be rendered inside <{}>.", element.name)
1089
+ };
1090
+ return Err(semantic_error(
1091
+ "html.semantic.raw_text_render",
1092
+ message,
1093
+ element.span.clone(),
1094
+ ));
1095
+ }
1096
+ }
1097
+ }
1098
+ out.push_str("</");
1099
+ out.push_str(&element.name);
1100
+ out.push('>');
1101
+ Ok(())
1102
+ }
1103
+
1069
1104
  fn render_html_element(
1070
1105
  name: &str,
1071
1106
  attributes: &[AttributeLike],
@@ -1267,6 +1302,30 @@ pub fn render_child_value(value: &RuntimeValue, out: &mut String) -> BackendResu
1267
1302
  Ok(())
1268
1303
  }
1269
1304
 
1305
+ fn render_escaped_text_value(value: &RuntimeValue, out: &mut String) -> BackendResult<()> {
1306
+ match value {
1307
+ RuntimeValue::Null => {}
1308
+ RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
1309
+ RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
1310
+ RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
1311
+ RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
1312
+ RuntimeValue::RawHtml(value) => out.push_str(&escape_html_text(value)),
1313
+ RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
1314
+ for value in values {
1315
+ render_escaped_text_value(value, out)?;
1316
+ }
1317
+ }
1318
+ RuntimeValue::Attributes(_) => {
1319
+ return Err(runtime_error(
1320
+ "html.runtime.child_type",
1321
+ "Mapping-like values cannot be rendered as children.",
1322
+ None,
1323
+ ));
1324
+ }
1325
+ }
1326
+ Ok(())
1327
+ }
1328
+
1270
1329
  fn render_attribute_value_string(
1271
1330
  value: &AttributeValue,
1272
1331
  context: &RuntimeContext,
@@ -1664,4 +1723,34 @@ mod tests {
1664
1723
  .expect("normalize class");
1665
1724
  assert_eq!(values, vec!["foo", "bar", "baz"]);
1666
1725
  }
1726
+
1727
+ #[test]
1728
+ fn title_interpolation_is_allowed_and_escaped_on_render() {
1729
+ let input = TemplateInput::from_segments(vec![
1730
+ TemplateSegment::StaticText("<title>".to_string()),
1731
+ interpolation(0, "title", Some("{title}")),
1732
+ TemplateSegment::StaticText("</title>".to_string()),
1733
+ ]);
1734
+ let compiled = compile_template(&input).expect("compile title template");
1735
+ let rendered = render_html(
1736
+ &compiled,
1737
+ &RuntimeContext {
1738
+ values: vec![RuntimeValue::RawHtml("<safe>".to_string())],
1739
+ },
1740
+ )
1741
+ .expect("render title");
1742
+ assert_eq!(rendered, "<title>&lt;safe&gt;</title>");
1743
+ }
1744
+
1745
+ #[test]
1746
+ fn script_interpolation_is_still_rejected() {
1747
+ let input = TemplateInput::from_segments(vec![
1748
+ TemplateSegment::StaticText("<script>".to_string()),
1749
+ interpolation(0, "script", Some("{script}")),
1750
+ TemplateSegment::StaticText("</script>".to_string()),
1751
+ ]);
1752
+ let err = check_template(&input).expect_err("script must still fail");
1753
+ assert_eq!(err.kind, ErrorKind::Semantic);
1754
+ assert!(err.message.contains("<script>"));
1755
+ }
1667
1756
  }
@@ -9,5 +9,5 @@ rust-version.workspace = true
9
9
  version.workspace = true
10
10
 
11
11
  [dependencies]
12
- tstring-html = { version = "0.1.4", path = "../tstring-html-rs" }
12
+ tstring-html = { version = "0.1.6", path = "../tstring-html-rs" }
13
13
  tstring-syntax.workspace = true
@@ -92,26 +92,7 @@ fn validate_thtml_node(node: &Node) -> BackendResult<()> {
92
92
  }
93
93
  Node::RawTextElement(element) => {
94
94
  validate_attributes(&element.attributes)?;
95
- for child in &element.children {
96
- match child {
97
- Node::Interpolation(interpolation) => {
98
- return Err(semantic_error(
99
- "html.semantic.raw_text_interpolation",
100
- format!("Interpolations are not allowed inside <{}>.", element.name),
101
- interpolation.span.clone(),
102
- ));
103
- }
104
- Node::Text(_) => {}
105
- _ => {
106
- return Err(semantic_error(
107
- "html.semantic.raw_text_content",
108
- format!("Only text is allowed inside <{}>.", element.name),
109
- element.span.clone(),
110
- ));
111
- }
112
- }
113
- }
114
- Ok(())
95
+ validate_raw_text_children(element)
115
96
  }
116
97
  Node::ComponentTag(component) => {
117
98
  validate_attributes(&component.attributes)?;
@@ -130,6 +111,38 @@ fn validate_thtml_node(node: &Node) -> BackendResult<()> {
130
111
  }
131
112
  }
132
113
 
114
+ fn validate_raw_text_children(element: &tstring_html::RawTextElementNode) -> BackendResult<()> {
115
+ for child in &element.children {
116
+ match child {
117
+ Node::Text(_) => {}
118
+ Node::Interpolation(_) if element.name.eq_ignore_ascii_case("title") => {}
119
+ Node::Interpolation(interpolation) => {
120
+ return Err(semantic_error(
121
+ "html.semantic.raw_text_interpolation",
122
+ format!("Interpolations are not allowed inside <{}>.", element.name),
123
+ interpolation.span.clone(),
124
+ ));
125
+ }
126
+ _ => {
127
+ let message = if element.name.eq_ignore_ascii_case("title") {
128
+ format!(
129
+ "Only text and interpolations are allowed inside <{}>.",
130
+ element.name
131
+ )
132
+ } else {
133
+ format!("Only text is allowed inside <{}>.", element.name)
134
+ };
135
+ return Err(semantic_error(
136
+ "html.semantic.raw_text_content",
137
+ message,
138
+ element.span.clone(),
139
+ ));
140
+ }
141
+ }
142
+ }
143
+ Ok(())
144
+ }
145
+
133
146
  fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
134
147
  for attribute in attributes {
135
148
  match attribute {
@@ -205,4 +218,24 @@ mod tests {
205
218
  "Component rendering requires the bindings layer runtime context."
206
219
  );
207
220
  }
221
+
222
+ #[test]
223
+ fn thtml_accepts_title_interpolation() {
224
+ let input = TemplateInput::from_segments(vec![
225
+ TemplateSegment::StaticText("<title>".to_string()),
226
+ TemplateSegment::Interpolation(tstring_syntax::TemplateInterpolation {
227
+ expression: "title".to_string(),
228
+ conversion: None,
229
+ format_spec: String::new(),
230
+ interpolation_index: 0,
231
+ raw_source: Some("{title}".to_string()),
232
+ }),
233
+ TemplateSegment::StaticText("</title>".to_string()),
234
+ ]);
235
+ check_template(&input).expect("title interpolation should be allowed");
236
+ assert_eq!(
237
+ format_template(&input).expect("format title"),
238
+ "<title>{title}</title>"
239
+ );
240
+ }
208
241
  }