tstring-html-bindings 0.1.9__tar.gz → 0.2.1__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.9 → tstring_html_bindings-0.2.1}/Cargo.lock +8 -8
  2. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/Cargo.toml +2 -2
  3. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/PKG-INFO +1 -1
  4. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/pyproject.toml +1 -1
  5. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-bindings/Cargo.toml +2 -2
  6. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-bindings/src/lib.rs +273 -35
  7. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-rs/Cargo.toml +1 -1
  8. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-rs/src/formatter.rs +23 -5
  9. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-rs/src/lib.rs +180 -103
  10. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-thtml-rs/Cargo.toml +1 -1
  11. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/README.md +0 -0
  12. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/python/tstring_html_bindings/__init__.py +0 -0
  13. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/Cargo.toml +0 -0
  14. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/src/lib.rs +0 -0
  15. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-html-bindings/README.md +0 -0
  16. {tstring_html_bindings-0.1.9 → tstring_html_bindings-0.2.1}/tstring-thtml-rs/src/lib.rs +0 -0
@@ -290,14 +290,14 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
290
290
 
291
291
  [[package]]
292
292
  name = "tstring-format-doc"
293
- version = "0.1.9"
293
+ version = "0.2.1"
294
294
  dependencies = [
295
295
  "unicode-width",
296
296
  ]
297
297
 
298
298
  [[package]]
299
299
  name = "tstring-html"
300
- version = "0.1.9"
300
+ version = "0.2.1"
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.9"
308
+ version = "0.2.1"
309
309
  dependencies = [
310
310
  "serde",
311
311
  "toml",
@@ -317,7 +317,7 @@ dependencies = [
317
317
 
318
318
  [[package]]
319
319
  name = "tstring-html-bindings"
320
- version = "0.1.9"
320
+ version = "0.2.1"
321
321
  dependencies = [
322
322
  "pyo3",
323
323
  "tstring-html",
@@ -327,16 +327,16 @@ dependencies = [
327
327
 
328
328
  [[package]]
329
329
  name = "tstring-syntax"
330
- version = "0.2.1"
330
+ version = "0.2.2"
331
331
  source = "registry+https://github.com/rust-lang/crates.io-index"
332
- checksum = "9ad566997891426a640b07af2330b9c08ad04e918f905880885d78a21304d9ee"
332
+ checksum = "2e6021e5e8f36304512dedeb70fce2c8f75a37eb6d823d1747b02e31c5fadd75"
333
333
  dependencies = [
334
334
  "num-bigint",
335
335
  ]
336
336
 
337
337
  [[package]]
338
338
  name = "tstring-tdom"
339
- version = "0.1.9"
339
+ version = "0.2.1"
340
340
  dependencies = [
341
341
  "tstring-format-doc",
342
342
  "tstring-syntax",
@@ -344,7 +344,7 @@ dependencies = [
344
344
 
345
345
  [[package]]
346
346
  name = "tstring-thtml"
347
- version = "0.1.9"
347
+ version = "0.2.1"
348
348
  dependencies = [
349
349
  "tstring-html",
350
350
  "tstring-syntax",
@@ -9,9 +9,9 @@ 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.9"
12
+ version = "0.2.1"
13
13
 
14
14
  [workspace.dependencies]
15
15
  pyo3 = "0.27.1"
16
- tstring-syntax = { version = "=0.2.1" }
16
+ tstring-syntax = { version = "0.2.2" }
17
17
  unicode-width = "0.2.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tstring-html-bindings
3
- Version: 0.1.9
3
+ Version: 0.2.1
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.9"
3
+ version = "0.2.1"
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.9", path = "../tstring-html-rs" }
23
- tstring-thtml = { version = "0.1.9", path = "../tstring-thtml-rs" }
22
+ tstring-html = { version = "0.2.1", path = "../tstring-html-rs" }
23
+ tstring-thtml = { version = "0.2.1", path = "../tstring-thtml-rs" }
24
24
  tstring-syntax.workspace = true
25
25
 
26
26
  [dev-dependencies]
@@ -1,7 +1,7 @@
1
1
  use pyo3::create_exception;
2
2
  use pyo3::exceptions::{PyException, PyTypeError};
3
3
  use pyo3::prelude::*;
4
- use pyo3::types::{PyAny, PyBool, PyDict, PyIterator, PyModule};
4
+ use pyo3::types::{PyAny, PyBool, PyByteArray, PyBytes, PyDict, PyIterator, PyModule};
5
5
  use std::collections::{HashMap, VecDeque};
6
6
  use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
7
7
  use tstring_html::{
@@ -178,12 +178,14 @@ struct PyCompiledThtmlTemplate {
178
178
  #[pymethods]
179
179
  impl PyCompiledHtmlTemplate {
180
180
  fn render(&self, py: Python<'_>, values: Vec<Py<PyAny>>) -> PyResult<String> {
181
- let context = runtime_context_from_values(py, &values)?;
181
+ let context =
182
+ runtime_context_from_values_for_document(py, &values, self.compiled.document())?;
182
183
  render_html_compiled(self.compiled.as_ref(), &context).map_err(backend_error_to_py)
183
184
  }
184
185
 
185
186
  fn render_fragment(&self, py: Python<'_>, values: Vec<Py<PyAny>>) -> PyResult<String> {
186
- let context = runtime_context_from_values(py, &values)?;
187
+ let context =
188
+ runtime_context_from_values_for_document(py, &values, self.compiled.document())?;
187
189
  Ok(
188
190
  tstring_html::render_fragment(self.compiled.as_ref(), &context)
189
191
  .map_err(backend_error_to_py)?
@@ -214,7 +216,8 @@ impl PyCompiledThtmlTemplate {
214
216
  registry,
215
217
  "CompiledThtmlTemplate.render",
216
218
  )?;
217
- let context = runtime_context_from_values(py, &values)?;
219
+ let context =
220
+ runtime_context_from_values_for_document(py, &values, self.compiled.document())?;
218
221
  render_thtml_document(
219
222
  py,
220
223
  self.compiled.document(),
@@ -235,6 +238,20 @@ struct BoundTemplate {
235
238
  values: Vec<Py<PyAny>>,
236
239
  }
237
240
 
241
+ #[derive(Clone, Debug)]
242
+ struct InterpolationFormatting {
243
+ interpolation_index: usize,
244
+ expression: String,
245
+ conversion: Option<String>,
246
+ format_spec: String,
247
+ }
248
+
249
+ impl InterpolationFormatting {
250
+ fn needs_formatting(&self) -> bool {
251
+ self.conversion.is_some() || !self.format_spec.is_empty()
252
+ }
253
+ }
254
+
238
255
  impl BoundTemplate {
239
256
  fn cache_key_strings(&self) -> &[String] {
240
257
  &self.strings
@@ -368,14 +385,22 @@ fn runtime_value_from_py(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<R
368
385
  if value.is_none() {
369
386
  return Ok(RuntimeValue::Null);
370
387
  }
371
- if let Ok(marker) = value.getattr("__tstring_renderable__") {
372
- if marker.is_truthy()? {
373
- let rendered = value.call_method0("render")?;
374
- let rendered: String = rendered
375
- .extract()
376
- .map_err(|_| runtime_error_to_py("Renderable.render() must return a string."))?;
377
- return Ok(RuntimeValue::RawHtml(rendered));
378
- }
388
+ if is_template_value(value)? {
389
+ return Err(runtime_error_to_py(
390
+ "Template values cannot be rendered directly; wrap them with html() or thtml().",
391
+ ));
392
+ }
393
+ if is_binary_value(value) {
394
+ return Err(runtime_error_to_py(
395
+ "bytes and bytearray values cannot be rendered.",
396
+ ));
397
+ }
398
+ if is_renderable_value(value)? {
399
+ let rendered = value.call_method0("render")?;
400
+ let rendered: String = rendered
401
+ .extract()
402
+ .map_err(|_| runtime_error_to_py("Renderable.render() must return a string."))?;
403
+ return Ok(RuntimeValue::RawHtml(rendered));
379
404
  }
380
405
  if let Ok(raw) = value.extract::<bool>() {
381
406
  return Ok(RuntimeValue::Bool(raw));
@@ -417,19 +442,234 @@ fn runtime_value_from_py(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<R
417
442
  }
418
443
 
419
444
  fn runtime_context_from_bound(py: Python<'_>, bound: &BoundTemplate) -> PyResult<RuntimeContext> {
420
- runtime_context_from_values(py, &bound.values)
445
+ runtime_context_from_values_with_formatting(
446
+ py,
447
+ &bound.values,
448
+ collect_input_formatting(&bound.input),
449
+ )
450
+ }
451
+
452
+ fn runtime_context_from_values_for_document(
453
+ py: Python<'_>,
454
+ values: &[Py<PyAny>],
455
+ document: &Document,
456
+ ) -> PyResult<RuntimeContext> {
457
+ runtime_context_from_values_with_formatting(py, values, collect_document_formatting(document))
421
458
  }
422
459
 
423
- fn runtime_context_from_values(py: Python<'_>, values: &[Py<PyAny>]) -> PyResult<RuntimeContext> {
460
+ fn runtime_context_from_values_with_formatting(
461
+ py: Python<'_>,
462
+ values: &[Py<PyAny>],
463
+ formatting: Vec<InterpolationFormatting>,
464
+ ) -> PyResult<RuntimeContext> {
465
+ let mut formatting_by_index = vec![None; values.len()];
466
+ for item in formatting {
467
+ if !item.needs_formatting() {
468
+ continue;
469
+ }
470
+ if let Some(slot) = formatting_by_index.get_mut(item.interpolation_index) {
471
+ if slot.is_none() {
472
+ *slot = Some(item);
473
+ }
474
+ }
475
+ }
476
+
424
477
  let mut runtime_values = Vec::with_capacity(values.len());
425
- for value in values {
426
- runtime_values.push(runtime_value_from_py(py, value.bind(py))?);
478
+ for (index, value) in values.iter().enumerate() {
479
+ let formatted = match formatting_by_index.get(index).and_then(Option::as_ref) {
480
+ Some(formatting) => apply_interpolation_formatting(py, value.bind(py), formatting)?,
481
+ None => value.clone_ref(py),
482
+ };
483
+ runtime_values.push(runtime_value_from_py(py, formatted.bind(py))?);
427
484
  }
428
485
  Ok(RuntimeContext {
429
486
  values: runtime_values,
430
487
  })
431
488
  }
432
489
 
490
+ fn collect_input_formatting(input: &TemplateInput) -> Vec<InterpolationFormatting> {
491
+ input
492
+ .segments
493
+ .iter()
494
+ .filter_map(|segment| match segment {
495
+ tstring_syntax::TemplateSegment::Interpolation(interpolation) => {
496
+ Some(formatting_from_template_interpolation(interpolation))
497
+ }
498
+ tstring_syntax::TemplateSegment::StaticText(_) => None,
499
+ })
500
+ .collect()
501
+ }
502
+
503
+ fn collect_document_formatting(document: &Document) -> Vec<InterpolationFormatting> {
504
+ let mut formatting = Vec::new();
505
+ collect_node_formatting(&document.children, &mut formatting);
506
+ formatting
507
+ }
508
+
509
+ fn collect_node_formatting(nodes: &[Node], formatting: &mut Vec<InterpolationFormatting>) {
510
+ for node in nodes {
511
+ match node {
512
+ Node::Fragment(fragment) => collect_node_formatting(&fragment.children, formatting),
513
+ Node::Element(element) => {
514
+ collect_attribute_formatting(&element.attributes, formatting);
515
+ collect_node_formatting(&element.children, formatting);
516
+ }
517
+ Node::ComponentTag(component) => {
518
+ collect_attribute_formatting(&component.attributes, formatting);
519
+ collect_node_formatting(&component.children, formatting);
520
+ }
521
+ Node::RawTextElement(element) => {
522
+ collect_attribute_formatting(&element.attributes, formatting);
523
+ collect_node_formatting(&element.children, formatting);
524
+ }
525
+ Node::Interpolation(interpolation) => {
526
+ formatting.push(formatting_from_node_interpolation(interpolation));
527
+ }
528
+ Node::Text(_) | Node::Comment(_) | Node::Doctype(_) => {}
529
+ }
530
+ }
531
+ }
532
+
533
+ fn collect_attribute_formatting(
534
+ attributes: &[AttributeLike],
535
+ formatting: &mut Vec<InterpolationFormatting>,
536
+ ) {
537
+ for attribute in attributes {
538
+ match attribute {
539
+ AttributeLike::Attribute(attribute) => {
540
+ if let Some(value) = &attribute.value {
541
+ for part in &value.parts {
542
+ if let tstring_html::ValuePart::Interpolation(interpolation) = part {
543
+ formatting.push(formatting_from_node_interpolation(interpolation));
544
+ }
545
+ }
546
+ }
547
+ }
548
+ AttributeLike::SpreadAttribute(attribute) => {
549
+ formatting.push(formatting_from_node_interpolation(&attribute.interpolation));
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ fn formatting_from_template_interpolation(
556
+ interpolation: &TemplateInterpolation,
557
+ ) -> InterpolationFormatting {
558
+ InterpolationFormatting {
559
+ interpolation_index: interpolation.interpolation_index,
560
+ expression: interpolation.expression.clone(),
561
+ conversion: interpolation.conversion.clone(),
562
+ format_spec: interpolation.format_spec.clone(),
563
+ }
564
+ }
565
+
566
+ fn formatting_from_node_interpolation(
567
+ interpolation: &tstring_html::InterpolationNode,
568
+ ) -> InterpolationFormatting {
569
+ InterpolationFormatting {
570
+ interpolation_index: interpolation.interpolation_index,
571
+ expression: interpolation.expression.clone(),
572
+ conversion: interpolation.conversion.clone(),
573
+ format_spec: interpolation.format_spec.clone(),
574
+ }
575
+ }
576
+
577
+ fn apply_interpolation_formatting(
578
+ py: Python<'_>,
579
+ value: &Bound<'_, PyAny>,
580
+ formatting: &InterpolationFormatting,
581
+ ) -> PyResult<Py<PyAny>> {
582
+ if is_structural_format_value(value)? {
583
+ return Err(runtime_error_to_py(format!(
584
+ "Interpolation '{}' uses conversion or format_spec on a structured value.",
585
+ formatting.expression
586
+ )));
587
+ }
588
+
589
+ let mut current = match formatting.conversion.as_deref() {
590
+ None => value.clone().unbind(),
591
+ Some("r") => value
592
+ .repr()
593
+ .map_err(|err| formatting_runtime_error("repr", formatting, err))?
594
+ .unbind()
595
+ .into_any(),
596
+ Some("s") => value
597
+ .str()
598
+ .map_err(|err| formatting_runtime_error("str", formatting, err))?
599
+ .unbind()
600
+ .into_any(),
601
+ Some("a") => py
602
+ .import("builtins")?
603
+ .getattr("ascii")?
604
+ .call1((value,))
605
+ .map_err(|err| formatting_runtime_error("ascii", formatting, err))?
606
+ .unbind(),
607
+ Some(conversion) => {
608
+ return Err(runtime_error_to_py(format!(
609
+ "Unsupported conversion !{conversion} for interpolation '{}'.",
610
+ formatting.expression
611
+ )));
612
+ }
613
+ };
614
+
615
+ if formatting.format_spec.is_empty() {
616
+ return Ok(current);
617
+ }
618
+
619
+ current = current
620
+ .bind(py)
621
+ .call_method1("__format__", (formatting.format_spec.as_str(),))
622
+ .map_err(|err| formatting_runtime_error("format", formatting, err))?
623
+ .unbind();
624
+ Ok(current)
625
+ }
626
+
627
+ fn formatting_runtime_error(
628
+ operation: &str,
629
+ formatting: &InterpolationFormatting,
630
+ err: PyErr,
631
+ ) -> PyErr {
632
+ runtime_error_to_py(format!(
633
+ "Failed to apply {operation} for interpolation '{}': {err}",
634
+ formatting.expression
635
+ ))
636
+ }
637
+
638
+ fn is_structural_format_value(value: &Bound<'_, PyAny>) -> PyResult<bool> {
639
+ if is_template_value(value)? || is_binary_value(value) || is_renderable_value(value)? {
640
+ return Ok(true);
641
+ }
642
+ if value.extract::<PyRef<'_, RawHtml>>().is_ok()
643
+ || value.extract::<PyRef<'_, Fragment>>().is_ok()
644
+ || value.cast::<PyDict>().is_ok()
645
+ {
646
+ return Ok(true);
647
+ }
648
+ if value.extract::<String>().is_ok() {
649
+ return Ok(false);
650
+ }
651
+ Ok(PyIterator::from_object(value).is_ok())
652
+ }
653
+
654
+ fn is_renderable_value(value: &Bound<'_, PyAny>) -> PyResult<bool> {
655
+ match value.getattr("__tstring_renderable__") {
656
+ Ok(marker) => marker.is_truthy(),
657
+ Err(_) => Ok(false),
658
+ }
659
+ }
660
+
661
+ fn is_template_value(value: &Bound<'_, PyAny>) -> PyResult<bool> {
662
+ if value.get_type().name()? != "Template" {
663
+ return Ok(false);
664
+ }
665
+ let module: String = value.get_type().getattr("__module__")?.extract()?;
666
+ Ok(module == "string.templatelib")
667
+ }
668
+
669
+ fn is_binary_value(value: &Bound<'_, PyAny>) -> bool {
670
+ value.cast::<PyBytes>().is_ok() || value.cast::<PyByteArray>().is_ok()
671
+ }
672
+
433
673
  fn registry_to_scope_dict<'py>(
434
674
  py: Python<'py>,
435
675
  registry: &Bound<'py, PyAny>,
@@ -614,6 +854,11 @@ fn render_thtml_node(
614
854
  };
615
855
  if let RuntimeValue::Attributes(entries) = value {
616
856
  for (key, value) in entries {
857
+ if !tstring_html::is_valid_html_attribute_name(key) {
858
+ return Err(runtime_error_to_py(format!(
859
+ "Spread attribute name {key:?} is not a valid HTML attribute name."
860
+ )));
861
+ }
617
862
  kwargs.set_item(key, python_from_runtime_value(py, value)?)?;
618
863
  }
619
864
  } else {
@@ -695,7 +940,7 @@ fn render_thtml_node(
695
940
  out.push_str("-->");
696
941
  }
697
942
  Node::Doctype(doctype) => {
698
- out.push_str("<!DOCTYPE ");
943
+ out.push_str("<!");
699
944
  out.push_str(&doctype.value);
700
945
  out.push('>');
701
946
  }
@@ -711,11 +956,17 @@ fn render_thtml_node(
711
956
  fn render_escaped_text_value(value: &RuntimeValue, out: &mut String) -> Result<(), BackendError> {
712
957
  match value {
713
958
  RuntimeValue::Null => {}
714
- RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
715
- RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
716
- RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
717
- RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
718
- RuntimeValue::RawHtml(value) => out.push_str(&escape_html_text(value)),
959
+ RuntimeValue::Bool(value) => {
960
+ out.push_str(&tstring_html::escape_html_text(&value.to_string()))
961
+ }
962
+ RuntimeValue::Int(value) => {
963
+ out.push_str(&tstring_html::escape_html_text(&value.to_string()))
964
+ }
965
+ RuntimeValue::Float(value) => {
966
+ out.push_str(&tstring_html::escape_html_text(&value.to_string()))
967
+ }
968
+ RuntimeValue::String(value) => out.push_str(&tstring_html::escape_html_text(value)),
969
+ RuntimeValue::RawHtml(value) => out.push_str(&tstring_html::escape_html_text(value)),
719
970
  RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
720
971
  for value in values {
721
972
  render_escaped_text_value(value, out)?;
@@ -732,19 +983,6 @@ fn render_escaped_text_value(value: &RuntimeValue, out: &mut String) -> Result<(
732
983
  Ok(())
733
984
  }
734
985
 
735
- fn escape_html_text(value: &str) -> String {
736
- let mut escaped = String::with_capacity(value.len());
737
- for ch in value.chars() {
738
- match ch {
739
- '&' => escaped.push_str("&amp;"),
740
- '<' => escaped.push_str("&lt;"),
741
- '>' => escaped.push_str("&gt;"),
742
- _ => escaped.push(ch),
743
- }
744
- }
745
- escaped
746
- }
747
-
748
986
  fn render_attribute_value_for_component(
749
987
  py: Python<'_>,
750
988
  value: &tstring_html::AttributeValue,
@@ -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.5", path = "../tstring-format-doc-rs" }
12
+ tstring-format-doc = { version = "0.2.1", path = "../tstring-format-doc-rs" }
13
13
  tstring-syntax.workspace = true
@@ -159,14 +159,15 @@ fn build_attribute(attribute: &Attribute) -> Doc {
159
159
  return Doc::text(attribute.name.clone());
160
160
  };
161
161
 
162
- let mut parts = vec![Doc::text(format!("{}=\"", attribute.name))];
162
+ let quote = attribute_quote(value);
163
+ let mut parts = vec![Doc::text(format!("{}={quote}", attribute.name))];
163
164
  for part in &value.parts {
164
165
  parts.push(match part {
165
- ValuePart::Text(text) => Doc::text(escape_attribute_text(text)),
166
+ ValuePart::Text(text) => Doc::text(escape_attribute_text(text, quote)),
166
167
  ValuePart::Interpolation(interpolation) => build_interpolation(interpolation),
167
168
  });
168
169
  }
169
- parts.push(Doc::text("\""));
170
+ parts.push(Doc::text(quote.to_string()));
170
171
  Doc::concat(parts)
171
172
  }
172
173
 
@@ -218,8 +219,25 @@ fn is_mixed_content(children: &[Node]) -> bool {
218
219
  })
219
220
  }
220
221
 
221
- fn escape_attribute_text(text: &str) -> String {
222
- text.replace('"', "&quot;")
222
+ fn attribute_quote(value: &crate::AttributeValue) -> char {
223
+ let mut has_single = false;
224
+ let mut has_double = false;
225
+ for part in &value.parts {
226
+ let ValuePart::Text(text) = part else {
227
+ continue;
228
+ };
229
+ has_single |= text.contains('\'');
230
+ has_double |= text.contains('"');
231
+ }
232
+ if has_double && !has_single { '\'' } else { '"' }
233
+ }
234
+
235
+ fn escape_attribute_text(text: &str, quote: char) -> String {
236
+ match quote {
237
+ '"' => text.replace('"', "&quot;"),
238
+ '\'' => text.replace('\'', "&#39;"),
239
+ _ => text.to_string(),
240
+ }
223
241
  }
224
242
 
225
243
  fn is_void_html_tag(name: &str) -> bool {
@@ -983,6 +983,13 @@ fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
983
983
  attribute.span.clone(),
984
984
  ));
985
985
  }
986
+ if let Some(static_value) = static_attribute_value(value) {
987
+ reject_dangerous_url_attribute_value(
988
+ &attribute.name,
989
+ &static_value,
990
+ attribute.span.clone(),
991
+ )?;
992
+ }
986
993
  }
987
994
  }
988
995
  AttributeLike::SpreadAttribute(_) => {}
@@ -1193,13 +1200,22 @@ fn render_attribute(
1193
1200
  RuntimeValue::Null => Ok(None),
1194
1201
  RuntimeValue::Bool(false) => Ok(None),
1195
1202
  RuntimeValue::Bool(true) => Ok(Some(None)),
1196
- other => Ok(Some(Some(escape_html_attribute(&stringify_runtime_value(
1197
- &other,
1198
- )?)))),
1203
+ other => {
1204
+ let raw = stringify_runtime_value(other)?;
1205
+ Ok(Some(Some(escape_html_attribute_value(
1206
+ &attribute.name,
1207
+ &raw,
1208
+ attribute.span.clone(),
1209
+ )?)))
1210
+ }
1199
1211
  };
1200
1212
  }
1201
1213
  let rendered = render_attribute_value_string(value, context, &attribute.name)?;
1202
- Ok(Some(Some(escape_html_attribute(&rendered))))
1214
+ Ok(Some(Some(escape_html_attribute_value(
1215
+ &attribute.name,
1216
+ &rendered,
1217
+ attribute.span.clone(),
1218
+ )?)))
1203
1219
  }
1204
1220
  }
1205
1221
  }
@@ -1212,37 +1228,51 @@ fn apply_spread_attribute(
1212
1228
  match value_for_interpolation(context, &attribute.interpolation)? {
1213
1229
  RuntimeValue::Attributes(entries) => {
1214
1230
  for (name, value) in entries {
1215
- if name == "class" {
1216
- normalized.saw_class = true;
1217
- if !normalized.order.iter().any(|entry| entry == "class") {
1218
- normalized.order.push("class".to_string());
1219
- }
1220
- normalized
1221
- .class_values
1222
- .extend(normalize_class_value(&value)?);
1223
- continue;
1231
+ if !is_valid_html_attribute_name(&name) {
1232
+ return Err(runtime_error(
1233
+ "html.runtime.spread_name",
1234
+ format!(
1235
+ "Spread attribute name {name:?} is not a valid HTML attribute name."
1236
+ ),
1237
+ attribute.span.clone(),
1238
+ ));
1224
1239
  }
1225
- match value {
1226
- RuntimeValue::Null | RuntimeValue::Bool(false) => {
1227
- normalized.attrs.remove(name.as_str());
1228
- }
1229
- RuntimeValue::Bool(true) => {
1230
- if !normalized.order.iter().any(|entry| entry == name) {
1231
- normalized.order.push(name.clone());
1240
+
1241
+ match name.as_str() {
1242
+ "class" => {
1243
+ normalized.saw_class = true;
1244
+ if !normalized.order.iter().any(|entry| entry == "class") {
1245
+ normalized.order.push("class".to_string());
1232
1246
  }
1233
- normalized.attrs.insert(name.clone(), None);
1247
+ normalized
1248
+ .class_values
1249
+ .extend(normalize_class_value(&value)?);
1234
1250
  }
1235
- other => {
1236
- if !normalized.order.iter().any(|entry| entry == name) {
1237
- normalized.order.push(name.clone());
1251
+ _ => match value {
1252
+ RuntimeValue::Null | RuntimeValue::Bool(false) => {
1253
+ normalized.attrs.remove(name.as_str());
1238
1254
  }
1239
- normalized.attrs.insert(
1240
- name.clone(),
1241
- Some(escape_html_attribute(&stringify_runtime_value_impl(
1242
- &other,
1243
- )?)),
1244
- );
1245
- }
1255
+ RuntimeValue::Bool(true) => {
1256
+ if !normalized.order.iter().any(|entry| entry == name) {
1257
+ normalized.order.push(name.clone());
1258
+ }
1259
+ normalized.attrs.insert(name.clone(), None);
1260
+ }
1261
+ other => {
1262
+ if !normalized.order.iter().any(|entry| entry == name) {
1263
+ normalized.order.push(name.clone());
1264
+ }
1265
+ let raw = stringify_runtime_value_impl(other)?;
1266
+ normalized.attrs.insert(
1267
+ name.clone(),
1268
+ Some(escape_html_attribute_value(
1269
+ &name,
1270
+ &raw,
1271
+ attribute.span.clone(),
1272
+ )?),
1273
+ );
1274
+ }
1275
+ },
1246
1276
  }
1247
1277
  }
1248
1278
  Ok(())
@@ -1472,93 +1502,104 @@ fn stringify_runtime_value_impl(value: &RuntimeValue) -> BackendResult<String> {
1472
1502
  }
1473
1503
  }
1474
1504
 
1475
- fn escape_html_text(value: &str) -> String {
1476
- value
1477
- .replace('&', "&amp;")
1478
- .replace('<', "&lt;")
1479
- .replace('>', "&gt;")
1505
+ #[must_use]
1506
+ pub fn escape_html_text(value: &str) -> String {
1507
+ escape_html(value, false)
1480
1508
  }
1481
1509
 
1482
- fn escape_html_attribute(value: &str) -> String {
1483
- let mut out = String::new();
1484
- let mut index = 0usize;
1510
+ #[must_use]
1511
+ pub fn escape_html_attribute(value: &str) -> String {
1512
+ escape_html(value, true)
1513
+ }
1485
1514
 
1486
- while index < value.len() {
1487
- let ch = value[index..]
1488
- .chars()
1489
- .next()
1490
- .expect("valid character boundary");
1515
+ fn escape_html(value: &str, escape_quote: bool) -> String {
1516
+ let mut out = String::with_capacity(value.len());
1517
+ for ch in value.chars() {
1491
1518
  match ch {
1492
- '&' => {
1493
- if let Some(entity_len) = html_entity_len(&value[index..]) {
1494
- out.push_str(&value[index..index + entity_len]);
1495
- index += entity_len;
1496
- } else {
1497
- out.push_str("&amp;");
1498
- index += 1;
1499
- }
1500
- }
1501
- '<' => {
1502
- out.push_str("&lt;");
1503
- index += 1;
1504
- }
1505
- '>' => {
1506
- out.push_str("&gt;");
1507
- index += 1;
1508
- }
1509
- '"' => {
1510
- out.push_str("&quot;");
1511
- index += 1;
1512
- }
1513
- _ => {
1514
- out.push(ch);
1515
- index += ch.len_utf8();
1516
- }
1519
+ '&' => out.push_str("&amp;"),
1520
+ '<' => out.push_str("&lt;"),
1521
+ '>' => out.push_str("&gt;"),
1522
+ '"' if escape_quote => out.push_str("&quot;"),
1523
+ _ => out.push(ch),
1517
1524
  }
1518
1525
  }
1519
-
1520
1526
  out
1521
1527
  }
1522
1528
 
1523
- fn html_entity_len(input: &str) -> Option<usize> {
1524
- let bytes = input.as_bytes();
1525
- if !bytes.starts_with(b"&") {
1526
- return None;
1527
- }
1528
-
1529
- let mut index = 1usize;
1530
- if bytes.get(index) == Some(&b'#') {
1531
- index += 1;
1532
- if matches!(bytes.get(index), Some(b'x' | b'X')) {
1533
- index += 1;
1534
- let start = index;
1535
- while bytes.get(index).is_some_and(u8::is_ascii_hexdigit) {
1536
- index += 1;
1537
- }
1538
- if index == start || bytes.get(index) != Some(&b';') {
1539
- return None;
1540
- }
1541
- return Some(index + 1);
1542
- }
1529
+ fn escape_html_attribute_value(
1530
+ name: &str,
1531
+ value: &str,
1532
+ span: Option<SourceSpan>,
1533
+ ) -> BackendResult<String> {
1534
+ reject_dangerous_url_attribute_value(name, value, span)?;
1535
+ Ok(escape_html_attribute(value))
1536
+ }
1543
1537
 
1544
- let start = index;
1545
- while bytes.get(index).is_some_and(u8::is_ascii_digit) {
1546
- index += 1;
1547
- }
1548
- if index == start || bytes.get(index) != Some(&b';') {
1549
- return None;
1538
+ fn static_attribute_value(value: &AttributeValue) -> Option<String> {
1539
+ let mut rendered = String::new();
1540
+ for part in &value.parts {
1541
+ match part {
1542
+ ValuePart::Text(text) => rendered.push_str(text),
1543
+ ValuePart::Interpolation(_) => return None,
1550
1544
  }
1551
- return Some(index + 1);
1552
1545
  }
1546
+ Some(rendered)
1547
+ }
1553
1548
 
1554
- let start = index;
1555
- while bytes.get(index).is_some_and(u8::is_ascii_alphanumeric) {
1556
- index += 1;
1549
+ fn reject_dangerous_url_attribute_value(
1550
+ name: &str,
1551
+ value: &str,
1552
+ span: Option<SourceSpan>,
1553
+ ) -> BackendResult<()> {
1554
+ if !is_url_attribute_name(name) {
1555
+ return Ok(());
1557
1556
  }
1558
- if index == start || bytes.get(index) != Some(&b';') {
1559
- return None;
1557
+ let Some(scheme) = dangerous_url_scheme(value) else {
1558
+ return Ok(());
1559
+ };
1560
+ Err(runtime_error(
1561
+ "html.runtime.url_scheme",
1562
+ format!("URL attribute '{name}' cannot use the unsafe {scheme} scheme."),
1563
+ span,
1564
+ ))
1565
+ }
1566
+
1567
+ fn is_url_attribute_name(name: &str) -> bool {
1568
+ const URL_ATTRIBUTE_NAMES: &[&str] = &[
1569
+ "href",
1570
+ "src",
1571
+ "action",
1572
+ "formaction",
1573
+ "xlink:href",
1574
+ "cite",
1575
+ "poster",
1576
+ "background",
1577
+ "manifest",
1578
+ ];
1579
+ URL_ATTRIBUTE_NAMES
1580
+ .iter()
1581
+ .any(|attribute| name.eq_ignore_ascii_case(attribute))
1582
+ }
1583
+
1584
+ fn dangerous_url_scheme(value: &str) -> Option<&'static str> {
1585
+ let mut normalized = String::with_capacity(value.len().min("javascript:".len()));
1586
+ for ch in value.chars() {
1587
+ if ch.is_control() || ch.is_whitespace() {
1588
+ continue;
1589
+ }
1590
+ normalized.push(ch.to_ascii_lowercase());
1591
+ match normalized.as_str() {
1592
+ "data:" => return Some("data:"),
1593
+ "vbscript:" => return Some("vbscript:"),
1594
+ "javascript:" => return Some("javascript:"),
1595
+ candidate
1596
+ if "data:".starts_with(candidate)
1597
+ || "vbscript:".starts_with(candidate)
1598
+ || "javascript:".starts_with(candidate) => {}
1599
+ _ => return None,
1600
+ }
1560
1601
  }
1561
- Some(index + 1)
1602
+ None
1562
1603
  }
1563
1604
 
1564
1605
  fn flatten_input(template: &TemplateInput) -> Vec<StreamItem> {
@@ -1605,6 +1646,14 @@ fn is_name_char(value: char, is_start: bool) -> bool {
1605
1646
  }
1606
1647
  }
1607
1648
 
1649
+ pub fn is_valid_html_attribute_name(name: &str) -> bool {
1650
+ let mut chars = name.chars();
1651
+ let Some(first) = chars.next() else {
1652
+ return false;
1653
+ };
1654
+ is_name_char(first, true) && chars.all(|ch| is_name_char(ch, false))
1655
+ }
1656
+
1608
1657
  fn parse_error(
1609
1658
  code: impl Into<String>,
1610
1659
  message: impl Into<String>,
@@ -1742,6 +1791,34 @@ mod tests {
1742
1791
  assert_eq!(rendered, "<title>&lt;safe&gt;</title>");
1743
1792
  }
1744
1793
 
1794
+ #[test]
1795
+ fn unsafe_static_url_attributes_are_rejected_during_check() {
1796
+ let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
1797
+ "<a href=\"java\n script:alert(1)\">x</a>".to_string(),
1798
+ )]);
1799
+ let err = check_template(&input).expect_err("unsafe URL scheme must fail");
1800
+ assert_eq!(err.kind, ErrorKind::Semantic);
1801
+ assert!(err.message.contains("unsafe javascript:"));
1802
+ }
1803
+
1804
+ #[test]
1805
+ fn attribute_escaping_always_escapes_ampersands() {
1806
+ let input = TemplateInput::from_segments(vec![
1807
+ TemplateSegment::StaticText("<div title=\"".to_string()),
1808
+ interpolation(0, "title", Some("{title}")),
1809
+ TemplateSegment::StaticText("\"></div>".to_string()),
1810
+ ]);
1811
+ let compiled = compile_template(&input).expect("compile html template");
1812
+ let rendered = render_html(
1813
+ &compiled,
1814
+ &RuntimeContext {
1815
+ values: vec![RuntimeValue::String("safe &amp; sound".to_string())],
1816
+ },
1817
+ )
1818
+ .expect("render html");
1819
+ assert_eq!(rendered, "<div title=\"safe &amp;amp; sound\"></div>");
1820
+ }
1821
+
1745
1822
  #[test]
1746
1823
  fn script_interpolation_is_still_rejected() {
1747
1824
  let input = TemplateInput::from_segments(vec![
@@ -9,5 +9,5 @@ rust-version.workspace = true
9
9
  version.workspace = true
10
10
 
11
11
  [dependencies]
12
- tstring-html = { version = "0.1.9", path = "../tstring-html-rs" }
12
+ tstring-html = { version = "0.2.1", path = "../tstring-html-rs" }
13
13
  tstring-syntax.workspace = true