tstring-html-bindings 0.1.10__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.
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/Cargo.lock +6 -6
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/Cargo.toml +2 -2
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/PKG-INFO +1 -1
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/pyproject.toml +1 -1
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/Cargo.toml +2 -2
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/src/lib.rs +273 -35
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-rs/Cargo.toml +1 -1
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-rs/src/formatter.rs +23 -5
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-rs/src/lib.rs +180 -103
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-thtml-rs/Cargo.toml +1 -1
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/README.md +0 -0
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/python/tstring_html_bindings/__init__.py +0 -0
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/Cargo.toml +0 -0
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/src/lib.rs +0 -0
- {tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/README.md +0 -0
- {tstring_html_bindings-0.1.10 → 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
|
|
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
|
|
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
|
|
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
|
|
320
|
+
version = "0.2.1"
|
|
321
321
|
dependencies = [
|
|
322
322
|
"pyo3",
|
|
323
323
|
"tstring-html",
|
|
@@ -336,7 +336,7 @@ dependencies = [
|
|
|
336
336
|
|
|
337
337
|
[[package]]
|
|
338
338
|
name = "tstring-tdom"
|
|
339
|
-
version = "0.1
|
|
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
|
|
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
|
|
12
|
+
version = "0.2.1"
|
|
13
13
|
|
|
14
14
|
[workspace.dependencies]
|
|
15
15
|
pyo3 = "0.27.1"
|
|
16
|
-
tstring-syntax = { version = "
|
|
16
|
+
tstring-syntax = { version = "0.2.2" }
|
|
17
17
|
unicode-width = "0.2.2"
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/Cargo.toml
RENAMED
|
@@ -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
|
|
23
|
-
tstring-thtml = { version = "0.1
|
|
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]
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/src/lib.rs
RENAMED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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("<!
|
|
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) =>
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
RuntimeValue::
|
|
718
|
-
|
|
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("&"),
|
|
740
|
-
'<' => escaped.push_str("<"),
|
|
741
|
-
'>' => escaped.push_str(">"),
|
|
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
|
|
12
|
+
tstring-format-doc = { version = "0.2.1", path = "../tstring-format-doc-rs" }
|
|
13
13
|
tstring-syntax.workspace = true
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-rs/src/formatter.rs
RENAMED
|
@@ -159,14 +159,15 @@ fn build_attribute(attribute: &Attribute) -> Doc {
|
|
|
159
159
|
return Doc::text(attribute.name.clone());
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
-
let
|
|
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
|
|
222
|
-
|
|
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('"', """),
|
|
238
|
+
'\'' => text.replace('\'', "'"),
|
|
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 =>
|
|
1197
|
-
|
|
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(
|
|
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
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
.
|
|
1222
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
|
1247
|
+
normalized
|
|
1248
|
+
.class_values
|
|
1249
|
+
.extend(normalize_class_value(&value)?);
|
|
1234
1250
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
normalized.
|
|
1251
|
+
_ => match value {
|
|
1252
|
+
RuntimeValue::Null | RuntimeValue::Bool(false) => {
|
|
1253
|
+
normalized.attrs.remove(name.as_str());
|
|
1238
1254
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
.replace('<', "<")
|
|
1479
|
-
.replace('>', ">")
|
|
1505
|
+
#[must_use]
|
|
1506
|
+
pub fn escape_html_text(value: &str) -> String {
|
|
1507
|
+
escape_html(value, false)
|
|
1480
1508
|
}
|
|
1481
1509
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1510
|
+
#[must_use]
|
|
1511
|
+
pub fn escape_html_attribute(value: &str) -> String {
|
|
1512
|
+
escape_html(value, true)
|
|
1513
|
+
}
|
|
1485
1514
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
out.push_str("&");
|
|
1498
|
-
index += 1;
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
'<' => {
|
|
1502
|
-
out.push_str("<");
|
|
1503
|
-
index += 1;
|
|
1504
|
-
}
|
|
1505
|
-
'>' => {
|
|
1506
|
-
out.push_str(">");
|
|
1507
|
-
index += 1;
|
|
1508
|
-
}
|
|
1509
|
-
'"' => {
|
|
1510
|
-
out.push_str(""");
|
|
1511
|
-
index += 1;
|
|
1512
|
-
}
|
|
1513
|
-
_ => {
|
|
1514
|
-
out.push(ch);
|
|
1515
|
-
index += ch.len_utf8();
|
|
1516
|
-
}
|
|
1519
|
+
'&' => out.push_str("&"),
|
|
1520
|
+
'<' => out.push_str("<"),
|
|
1521
|
+
'>' => out.push_str(">"),
|
|
1522
|
+
'"' if escape_quote => out.push_str("""),
|
|
1523
|
+
_ => out.push(ch),
|
|
1517
1524
|
}
|
|
1518
1525
|
}
|
|
1519
|
-
|
|
1520
1526
|
out
|
|
1521
1527
|
}
|
|
1522
1528
|
|
|
1523
|
-
fn
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
1559
|
-
return
|
|
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
|
-
|
|
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><safe></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 & sound".to_string())],
|
|
1816
|
+
},
|
|
1817
|
+
)
|
|
1818
|
+
.expect("render html");
|
|
1819
|
+
assert_eq!(rendered, "<div title=\"safe &amp; sound\"></div>");
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1745
1822
|
#[test]
|
|
1746
1823
|
fn script_interpolation_is_still_rejected() {
|
|
1747
1824
|
let input = TemplateInput::from_segments(vec![
|
|
File without changes
|
|
File without changes
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/Cargo.toml
RENAMED
|
File without changes
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-format-doc-rs/src/lib.rs
RENAMED
|
File without changes
|
{tstring_html_bindings-0.1.10 → tstring_html_bindings-0.2.1}/tstring-html-bindings/README.md
RENAMED
|
File without changes
|
|
File without changes
|