Coverage for tdom/processor.py: 98%

428 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-19 21:37 +0000

1import typing as t 

2from collections.abc import Callable, Iterable, Mapping, Sequence 

3from dataclasses import dataclass, field 

4from functools import lru_cache 

5from string.templatelib import Interpolation, Template 

6 

7from markupsafe import Markup 

8 

9from .callables import CallableInfo, get_callable_info 

10from .escaping import ( 

11 escape_html_comment as default_escape_html_comment, 

12) 

13from .escaping import ( 

14 escape_html_script as default_escape_html_script, 

15) 

16from .escaping import ( 

17 escape_html_style as default_escape_html_style, 

18) 

19from .escaping import ( 

20 escape_html_text as default_escape_html_text, 

21) 

22from .format import format_interpolation as base_format_interpolation 

23from .format import format_template 

24from .htmlspec import ( 

25 CDATA_CONTENT_ELEMENTS, 

26 DEFAULT_NORMAL_TEXT_ELEMENT, 

27 RCDATA_CONTENT_ELEMENTS, 

28 SVG_ATTR_FIX, 

29 SVG_TAG_FIX, 

30 VOID_ELEMENTS, 

31) 

32from .parser import TemplateParser 

33from .parser_utils import HTMLAttribute 

34from .protocols import HasHTMLDunder 

35from .scope import ScopedTemplate 

36from .template_utils import TemplateRef, TemplateSpan 

37from .tnodes import ( 

38 TAttribute, 

39 TComment, 

40 TComponent, 

41 TDocumentType, 

42 TElement, 

43 TFragment, 

44 TInterpolatedAttribute, 

45 TLiteralAttribute, 

46 TNode, 

47 TSpreadAttribute, 

48 TTemplatedAttribute, 

49 TText, 

50 TTree, 

51) 

52from .utils import CachableTemplate, LastUpdatedOrderedDict 

53 

54type Attribute = tuple[str, object] 

55type AttributesDict = dict[str, object] 

56 

57 

58# -------------------------------------------------------------------------- 

59# Custom formatting for the processor 

60# -------------------------------------------------------------------------- 

61 

62 

63def _format_safe(value: object, format_spec: str) -> str: 

64 """Use Markup() to mark a value as safe HTML.""" 

65 assert format_spec == "safe" 

66 return Markup(value) 

67 

68 

69def _format_unsafe(value: object, format_spec: str) -> str: 

70 """Convert a value to a plain string, forcing it to be treated as unsafe.""" 

71 assert format_spec == "unsafe" 

72 return str(value) 

73 

74 

75def _format_callback(value: Callable[..., object], format_spec: str) -> object: 

76 """Execute a callback and return the value.""" 

77 assert format_spec == "callback" 

78 return value() 

79 

80 

81CUSTOM_FORMATTERS = ( 

82 ("safe", _format_safe), 

83 ("unsafe", _format_unsafe), 

84 ("callback", _format_callback), 

85) 

86 

87 

88def format_interpolation(interpolation: Interpolation) -> object: 

89 return base_format_interpolation( 

90 interpolation, 

91 formatters=CUSTOM_FORMATTERS, 

92 ) 

93 

94 

95# -------------------------------------------------------------------------- 

96# Placeholder Substitution 

97# -------------------------------------------------------------------------- 

98 

99 

100def _expand_aria_attr(value: object) -> Iterable[HTMLAttribute]: 

101 """Produce aria-* attributes based on the interpolated value for "aria".""" 

102 if value is None: 

103 return 

104 elif isinstance(value, Mapping): 

105 for sub_k, sub_v in value.items(): 

106 if sub_v is True: 

107 yield f"aria-{sub_k}", "true" 

108 elif sub_v is False: 

109 yield f"aria-{sub_k}", "false" 

110 elif sub_v is None: 

111 yield f"aria-{sub_k}", None 

112 else: 

113 yield f"aria-{sub_k}", str(sub_v) 

114 else: 

115 raise TypeError( 

116 f"Cannot use {type(value).__name__} as value for aria attribute" 

117 ) 

118 

119 

120def _expand_data_attr(value: object) -> Iterable[Attribute]: 

121 """Produce data-* attributes based on the interpolated value for "data".""" 

122 if value is None: 

123 return 

124 elif isinstance(value, Mapping): 

125 for sub_k, sub_v in value.items(): 

126 if sub_v is True or sub_v is False or sub_v is None: 

127 yield f"data-{sub_k}", sub_v 

128 else: 

129 yield f"data-{sub_k}", str(sub_v) 

130 else: 

131 raise TypeError( 

132 f"Cannot use {type(value).__name__} as value for data attribute" 

133 ) 

134 

135 

136def _substitute_spread_attrs(value: object) -> Iterable[Attribute]: 

137 """ 

138 Substitute a spread attribute based on the interpolated value. 

139 

140 A spread attribute is one where the key is a placeholder, indicating that 

141 the entire attribute set should be replaced by the interpolated value. 

142 The value must be a Mapping. 

143 """ 

144 if value is None: 

145 return 

146 elif isinstance(value, Mapping): 

147 yield from value.items() 

148 else: 

149 raise TypeError( 

150 f"Cannot use {type(value).__name__} as value for spread attributes" 

151 ) 

152 

153 

154ATTR_EXPANDERS = { 

155 "data": _expand_data_attr, 

156 "aria": _expand_aria_attr, 

157} 

158 

159 

160def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]: 

161 """ 

162 Parse the style declarations out of a style attribute string. 

163 """ 

164 props = [p.strip() for p in style_str.split(";")] 

165 styles: list[tuple[str, str | None]] = [] 

166 for prop in props: 

167 if prop: 

168 prop_parts = [p.strip() for p in prop.split(":") if p.strip()] 

169 if len(prop_parts) != 2: 

170 raise ValueError( 

171 f"Invalid number of parts for style property {prop} in {style_str}" 

172 ) 

173 styles.append((prop_parts[0], prop_parts[1])) 

174 return styles 

175 

176 

177def make_style_accumulator(old_value: object) -> StyleAccumulator: 

178 """ 

179 Initialize the style accumulator. 

180 """ 

181 match old_value: 

182 case str(): 

183 styles = { 

184 name: value for name, value in parse_style_attribute_value(old_value) 

185 } 

186 case True: # A bare attribute will just default to {}. 

187 styles = {} 

188 case _: 

189 raise TypeError(f"Unexpected value: {old_value}") 

190 return StyleAccumulator(styles=styles) 

191 

192 

193@dataclass 

194class StyleAccumulator: 

195 styles: dict[str, str | None] 

196 

197 def merge_value(self, value: object) -> None: 

198 """ 

199 Merge in an interpolated style value. 

200 """ 

201 match value: 

202 case str(): 

203 self.styles.update( 

204 {name: value for name, value in parse_style_attribute_value(value)} 

205 ) 

206 case Mapping(): 

207 self.styles.update( 

208 { 

209 str(pn): str(pv) if pv is not None else None 

210 for pn, pv in value.items() 

211 } 

212 ) 

213 case None: 

214 pass 

215 case _: 

216 raise TypeError( 

217 f"Unknown interpolated style value {value}, use '' to omit." 

218 ) 

219 

220 def to_value(self) -> str | None: 

221 """ 

222 Serialize the special style value back into a string. 

223 

224 @NOTE: If the result would be `''` then use `None` to omit the attribute. 

225 """ 

226 style_value = "; ".join( 

227 [f"{pn}: {pv}" for pn, pv in self.styles.items() if pv is not None] 

228 ) 

229 return style_value or None 

230 

231 

232def make_class_accumulator(old_value: object) -> ClassAccumulator: 

233 """ 

234 Initialize the class accumulator. 

235 """ 

236 match old_value: 

237 case str(): 

238 toggled_classes = dict.fromkeys(old_value.split(), True) 

239 case True: 

240 toggled_classes = {} 

241 case _: 

242 raise ValueError(f"Unexpected value {old_value}") 

243 return ClassAccumulator(toggled_classes=toggled_classes) 

244 

245 

246@dataclass 

247class ClassAccumulator: 

248 toggled_classes: dict[str, bool] 

249 

250 def merge_value(self, value: object) -> None: 

251 """ 

252 Merge in an interpolated class value. 

253 """ 

254 if isinstance(value, Mapping): 

255 self.toggled_classes.update( 

256 {str(cn): bool(toggle) for cn, toggle in value.items()} 

257 ) 

258 else: 

259 if not isinstance(value, str) and isinstance(value, Sequence): 

260 items = value[:] 

261 else: 

262 items = (value,) 

263 for item in items: 

264 match item: 

265 case str(): 

266 self.toggled_classes.update(dict.fromkeys(item.split(), True)) 

267 case None: 

268 pass 

269 case _: 

270 if item == value: 

271 raise TypeError( 

272 f"Unknown interpolated class value: {value}" 

273 ) 

274 else: 

275 raise TypeError( 

276 f"Unknown interpolated class item in {value}: {item}" 

277 ) 

278 

279 def to_value(self) -> str | None: 

280 """ 

281 Serialize the special class value back into a string. 

282 

283 @NOTE: If the result would be `''` then use `None` to omit the attribute. 

284 """ 

285 class_value = " ".join( 

286 [cn for cn, toggle in self.toggled_classes.items() if toggle] 

287 ) 

288 return class_value or None 

289 

290 

291ATTR_ACCUMULATOR_MAKERS = { 

292 "class": make_class_accumulator, 

293 "style": make_style_accumulator, 

294} 

295 

296 

297type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator 

298 

299 

300def _resolve_t_attrs(attrs: Sequence[TAttribute], template: Template) -> AttributesDict: 

301 """ 

302 Replace placeholder values in attributes with their interpolated values. 

303 

304 The values returned are not yet processed for HTML output; that is handled 

305 in a later step. 

306 

307 @NOTE: We "touch" the key when accumulating values so that we can predict 

308 what order that attribute will be ordered. We skip this step when setting 

309 the final value so that the order is not disturbed. 

310 """ 

311 new_attrs: AttributesDict = LastUpdatedOrderedDict() 

312 attr_accs: dict[str, AttributeValueAccumulator] = {} 

313 for attr in attrs: 

314 match attr: 

315 case TLiteralAttribute(name=name, value=value): 

316 attr_value = True if value is None else value 

317 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs: 

318 if name not in attr_accs: 

319 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs[name]) 

320 new_attrs[name] = attr_accs[name].merge_value(attr_value) 

321 else: 

322 new_attrs[name] = attr_value 

323 case TInterpolatedAttribute(name=name, value_i_index=i_index): 

324 interpolation = template.interpolations[i_index] 

325 attr_value = format_interpolation(interpolation) 

326 if name in ATTR_ACCUMULATOR_MAKERS: 

327 if name not in attr_accs: 

328 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

329 new_attrs.get(name, True) 

330 ) 

331 new_attrs[name] = attr_accs[name].merge_value(attr_value) 

332 elif expander := ATTR_EXPANDERS.get(name): 

333 for sub_k, sub_v in expander(attr_value): 

334 new_attrs[sub_k] = sub_v 

335 else: 

336 new_attrs[name] = attr_value 

337 case TTemplatedAttribute(name=name, value_ref=ref): 

338 attr_t = ref.bind(template.interpolations) 

339 attr_value = format_template(attr_t) 

340 if name in ATTR_ACCUMULATOR_MAKERS: 

341 if name not in attr_accs: 

342 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

343 new_attrs.get(name, True) 

344 ) 

345 new_attrs[name] = attr_accs[name].merge_value(attr_value) 

346 elif expander := ATTR_EXPANDERS.get(name): 

347 raise TypeError(f"{name} attributes cannot be templated") 

348 else: 

349 new_attrs[name] = attr_value 

350 case TSpreadAttribute(i_index=i_index): 

351 interpolation = template.interpolations[i_index] 

352 spread_value = format_interpolation(interpolation) 

353 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

354 if sub_k in ATTR_ACCUMULATOR_MAKERS: 

355 if sub_k not in attr_accs: 

356 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k]( 

357 new_attrs.get(sub_k, True) 

358 ) 

359 new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v) 

360 elif expander := ATTR_EXPANDERS.get(sub_k): 

361 for exp_k, exp_v in expander(sub_v): 

362 new_attrs[exp_k] = exp_v 

363 else: 

364 new_attrs[sub_k] = sub_v 

365 case _: 

366 raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}") 

367 for acc_name, acc in attr_accs.items(): 

368 # Skip "touching" the key here so that the order remains intact. 

369 super(type(new_attrs), new_attrs).__setitem__(acc_name, acc.to_value()) 

370 return new_attrs 

371 

372 

373def _resolve_html_attrs(attrs: AttributesDict) -> Iterable[HTMLAttribute]: 

374 """Resolve attribute values for HTML output.""" 

375 for key, value in attrs.items(): 

376 match value: 

377 case True: 

378 yield key, None 

379 case False | None: 

380 pass 

381 case _: 

382 yield key, str(value) 

383 

384 

385def _kebab_to_snake(name: str) -> str: 

386 """Convert a kebab-case name to snake_case.""" 

387 return name.replace("-", "_").lower() 

388 

389 

390def _prep_component_kwargs( 

391 callable_info: CallableInfo, 

392 attrs: AttributesDict, 

393 children: Template, 

394 provided_attrs: tuple[Attribute, ...] = (), 

395 raise_on_requires_positional=True, 

396 raise_on_missing=True, 

397) -> AttributesDict: 

398 """ 

399 Matchup kwargs from multiple sources to target the given callable. 

400 

401 `provided_attrs`: 

402 These can be used by extensions that want to provide 

403 attrs even if they are not specified in the component's `attrs` in 

404 the template. If an attribute with the same name is provided in 

405 `attrs` then it takes priority over entries in `provided_attrs`. 

406 

407 `raise_on_requires_positional`: 

408 Optionally check and raise `TypeError` if the `callable_info` requires 

409 positional arguments which we cannot fulfill normally. 

410 An exception might not be desired if the caller will finish preparing 

411 the arguments after this call. 

412 

413 `raise_on_missing`: 

414 Optionally check and raise `TypeError` if we are not able to fulfill all 

415 the arguments the `callable_info` expects since in the common case this 

416 raise an exception whose cause might not be clear. 

417 An exception might not be desired if the caller will finish preparing 

418 the arguments after this call. 

419 """ 

420 

421 # We can't know what kwarg to put here... 

422 if raise_on_requires_positional and callable_info.requires_positional: 

423 raise TypeError( 

424 "Component callables cannot have required positional arguments." 

425 ) 

426 

427 kwargs: AttributesDict = {} 

428 

429 # Add all supported attributes 

430 for attr_name, attr_value in attrs.items(): 

431 snake_name = _kebab_to_snake(attr_name) 

432 if snake_name in callable_info.named_params: 

433 if snake_name in kwargs: 

434 raise ValueError( 

435 f"Ambiguous attribute {attr_name}: Two attributes resolved to the same named param {snake_name}." 

436 ) 

437 kwargs[snake_name] = attr_value 

438 elif callable_info.kwargs: 

439 kwargs[attr_name] = attr_value # Retain original attribute name 

440 else: 

441 raise ValueError(f"Unexpected attribute {attr_name}.") 

442 

443 if "children" in kwargs: 

444 raise ValueError("The children attribute is reserved for component children.") 

445 

446 if "children" in callable_info.named_params: 

447 kwargs["children"] = children 

448 

449 # Add in provided attrs if they haven't been set already and are wanted. 

450 for pattr_name, pattr_value in provided_attrs: 

451 if pattr_name not in kwargs and pattr_name in callable_info.named_params: 

452 kwargs[pattr_name] = pattr_value 

453 

454 # Check to make sure we've fully satisfied the callable's requirements 

455 if raise_on_missing: 

456 missing = callable_info.required_named_params - kwargs.keys() 

457 if missing: 

458 raise TypeError( 

459 f"Missing required parameters for component: {', '.join(missing)}" 

460 ) 

461 

462 return kwargs 

463 

464 

465def serialize_html_attrs( 

466 html_attrs: Iterable[HTMLAttribute], escape: Callable = default_escape_html_text 

467) -> str: 

468 return "".join( 

469 (f' {k}="{escape(v)}"' if v is not None else f" {k}" for k, v in html_attrs) 

470 ) 

471 

472 

473def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribute]: 

474 """ 

475 Fix the attr name-case of any html attributes on a tag within an SVG namespace. 

476 """ 

477 for k, v in html_attrs: 

478 yield SVG_ATTR_FIX.get(k, k), v 

479 

480 

481@dataclass(frozen=True, slots=True) 

482class ProcessContext: 

483 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT 

484 ns: str = "html" 

485 

486 def copy( 

487 self, 

488 ns: str | None = None, 

489 parent_tag: str | None = None, 

490 ) -> ProcessContext: 

491 return ProcessContext( 

492 parent_tag=parent_tag if parent_tag is not None else self.parent_tag, 

493 ns=ns if ns is not None else self.ns, 

494 ) 

495 

496 

497type FunctionComponent = Callable[..., Template] 

498type FactoryComponent = Callable[..., ComponentObject] 

499type ComponentCallable = FunctionComponent | FactoryComponent 

500type ComponentObject = Callable[[], Template] 

501 

502 

503type NormalTextInterpolationValue = ( 

504 bool # to support `showValue and value` idiom 

505 | str 

506 | HasHTMLDunder 

507 | Template 

508 | Iterable[NormalTextInterpolationValue] 

509 | object 

510 | None 

511) 

512# Applies to both escapable raw text and raw text. 

513type RawTextExactInterpolationValue = ( 

514 bool # to support `showValue and value` idiom 

515 | str 

516 | HasHTMLDunder 

517 | object 

518 | None 

519) 

520# Applies to both escapable raw text and raw text. 

521type RawTextInexactInterpolationValue = ( 

522 bool # to support `showValue and value` idiom 

523 | str 

524 | object 

525 | None 

526) 

527 

528 

529class ITemplateParserProxy(t.Protocol): 

530 def to_ttree(self, template: Template) -> TTree: ... 

531 

532 

533@dataclass(frozen=True) 

534class TemplateParserProxy(ITemplateParserProxy): 

535 def to_ttree(self, template: Template) -> TTree: 

536 return TemplateParser.parse(template) 

537 

538 

539@dataclass(frozen=True) 

540class CachedTemplateParserProxy(TemplateParserProxy): 

541 @lru_cache(512) # noqa: B019 

542 def _to_ttree(self, ct: CachableTemplate) -> TTree: 

543 return super().to_ttree(ct.template) 

544 

545 def to_ttree(self, template: Template) -> TTree: 

546 return self._to_ttree(CachableTemplate(template)) 

547 

548 

549class IComponentProcessor(t.Protocol): 

550 """Isolate component processing to allow for replacement.""" 

551 

552 def process( 

553 self, 

554 template: Template, 

555 last_ctx: ProcessContext, 

556 component_callable: t.Annotated[object, ComponentCallable], 

557 attrs: tuple[TAttribute, ...], 

558 component_template: Template, 

559 provided_attrs: tuple[Attribute, ...] = (), 

560 ) -> Template | ScopedTemplate: 

561 """ 

562 Process available component details into a `Template` (or a 

563 `ScopedTemplate`, for context-provider components). 

564 """ 

565 ... 

566 

567 

568class ComponentProcessor(IComponentProcessor): 

569 """ 

570 Default component processor. 

571 """ 

572 

573 def process( 

574 self, 

575 template: Template, 

576 last_ctx: ProcessContext, 

577 component_callable: t.Annotated[object, ComponentCallable], 

578 attrs: tuple[TAttribute, ...], 

579 component_template: Template, 

580 provided_attrs: tuple[Attribute, ...] = (), 

581 ) -> Template | ScopedTemplate: 

582 """ 

583 Process available component details into a Template. 

584 

585 Two general "styles" are supported: 

586 

587 1. FunctionComponent 

588 

589 Calling `component_callable` with the prepared kwargs should 

590 return a `Template`. 

591 

592 The primary purpose of this style is to support 

593 using a normal function as a component. 

594 

595 2. FactoryComponent 

596 

597 Calling `component_callable` with the prepared kwargs should 

598 return another `Callable` which when called with no arguments should 

599 return a `Template`. 

600 

601 The primary purpose of this style is to support 

602 using a `dataclass` with `def __call__(self) -> Template` as a 

603 component. 

604 

605 Either style may instead return a `ScopedTemplate` -- a 

606 `Template` bundled with a `Scope` to activate around its render. 

607 Context providers (`tdom.make_provider(cv)` / 

608 `tdom.create_context(...)`) use this shape; user code generally 

609 won't construct one directly. 

610 """ 

611 if not callable(component_callable): 

612 raise TypeError( 

613 f"Component callable must be callable: {type(component_callable)}" 

614 ) 

615 kwargs = _prep_component_kwargs( 

616 get_callable_info(component_callable), 

617 _resolve_t_attrs(attrs, template), 

618 children=component_template, 

619 provided_attrs=provided_attrs, 

620 raise_on_requires_positional=True, 

621 raise_on_missing=True, 

622 ) 

623 res1 = component_callable(**kwargs) # ty: ignore[call-top-callable] 

624 if isinstance(res1, (Template, ScopedTemplate)): 

625 return res1 

626 elif callable(res1): 

627 res2 = res1() # ty: ignore[call-top-callable] 

628 if isinstance(res2, (Template, ScopedTemplate)): 

629 return res2 

630 else: 

631 raise TypeError( 

632 f"Component object must return Template when called: {type(res2)}" 

633 ) 

634 else: 

635 raise TypeError( 

636 f"Component callable must return Template or Callable: {type(res1)}" 

637 ) 

638 

639 

640class ITemplateProcessor(t.Protocol): 

641 def process(self, root_template: Template, assume_ctx: ProcessContext) -> str: ... 

642 

643 

644@dataclass(frozen=True) 

645class TemplateProcessor(ITemplateProcessor): 

646 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy) 

647 

648 component_processor_api: IComponentProcessor = field( 

649 default_factory=ComponentProcessor 

650 ) 

651 

652 escape_html_text: Callable = default_escape_html_text 

653 

654 escape_html_comment: Callable = default_escape_html_comment 

655 

656 escape_html_script: Callable = default_escape_html_script 

657 

658 escape_html_style: Callable = default_escape_html_style 

659 

660 slash_void: bool = False # Apply a xhtml-style slash to void html elements. 

661 

662 uppercase_doctype: bool = False # DOCTYPE vs doctype 

663 

664 def process( 

665 self, 

666 root_template: Template, 

667 assume_ctx: ProcessContext, 

668 ) -> str: 

669 """ 

670 Process a TDOM compatible template into a string. 

671 """ 

672 return self._process_template(root_template, assume_ctx) 

673 

674 def _process_template(self, template: Template, last_ctx: ProcessContext) -> str: 

675 ttree = self.parser_api.to_ttree(template) 

676 return self._process_tnode(template, last_ctx, ttree.root) 

677 

678 def _process_tnode( 

679 self, template: Template, last_ctx: ProcessContext, tnode: TNode 

680 ) -> str: 

681 """ 

682 Process a tnode from a template's "t-tree" into a string. 

683 """ 

684 match tnode: 

685 case TDocumentType(text): 

686 return self._process_document_type(last_ctx, text) 

687 case TComment(ref): 

688 return self._process_comment(template, last_ctx, ref) 

689 case TFragment(children): 

690 return self._process_fragment(template, last_ctx, children) 

691 case TComponent(start_i_index, end_i_index, children_span, attrs): 

692 return self._process_component( 

693 template, 

694 last_ctx, 

695 attrs, 

696 start_i_index, 

697 end_i_index, 

698 children_span, 

699 ) 

700 case TElement(tag, attrs, children): 

701 return self._process_element(template, last_ctx, tag, attrs, children) 

702 case TText(ref): 

703 return self._process_texts(template, last_ctx, ref) 

704 case _: 

705 raise ValueError(f"Unrecognized tnode: {tnode}") 

706 

707 def _process_document_type( 

708 self, 

709 last_ctx: ProcessContext, 

710 text: str, 

711 ) -> str: 

712 if last_ctx.ns != "html": 

713 # Nit 

714 raise ValueError( 

715 "Cannot process document type in subtree of a foreign element." 

716 ) 

717 if self.uppercase_doctype: 

718 return f"<!DOCTYPE {text}>" 

719 else: 

720 return f"<!doctype {text}>" 

721 

722 def _process_fragment( 

723 self, 

724 template: Template, 

725 last_ctx: ProcessContext, 

726 children: Iterable[TNode], 

727 ) -> str: 

728 return "".join( 

729 self._process_tnode(template, last_ctx, child) for child in children 

730 ) 

731 

732 def _process_texts( 

733 self, 

734 template: Template, 

735 last_ctx: ProcessContext, 

736 ref: TemplateRef, 

737 ) -> str: 

738 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: 

739 # Must be handled all at once. 

740 return self._process_raw_texts(template, last_ctx, ref) 

741 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: 

742 # We can handle all at once because there are no non-text children and everything must be string-ified. 

743 return self._process_escapable_raw_texts(template, last_ctx, ref) 

744 else: 

745 return self._process_normal_texts(template, last_ctx, ref) 

746 

747 def _process_comment( 

748 self, 

749 template: Template, 

750 last_ctx: ProcessContext, 

751 content_ref: TemplateRef, 

752 ) -> str: 

753 """ 

754 Process a comment into a string. 

755 """ 

756 content_str = resolve_text_without_recursion(template, "<!--", content_ref) 

757 escaped_comment_str = self.escape_html_comment(content_str, allow_markup=True) 

758 return f"<!--{escaped_comment_str}-->" 

759 

760 def _process_element( 

761 self, 

762 template: Template, 

763 last_ctx: ProcessContext, 

764 tag: str, 

765 attrs: tuple[TAttribute, ...], 

766 children: tuple[TNode, ...], 

767 ) -> str: 

768 out: list[str] = [] 

769 if tag == "svg": 

770 our_ctx = last_ctx.copy(parent_tag=tag, ns="svg") 

771 elif tag == "math": 

772 our_ctx = last_ctx.copy(parent_tag=tag, ns="math") 

773 else: 

774 our_ctx = last_ctx.copy(parent_tag=tag) 

775 if our_ctx.ns == "svg": 

776 starttag = endtag = SVG_TAG_FIX.get(tag, tag) 

777 else: 

778 starttag = endtag = tag 

779 out.append(f"<{starttag}") 

780 if attrs: 

781 out.append(self._process_attrs(template, our_ctx, attrs)) 

782 # @TODO: How can we tell if we write out children or not in 

783 # order to self-close in non-html contexts, ie. SVG? 

784 if self.slash_void and tag in VOID_ELEMENTS: 

785 out.append(" />") 

786 else: 

787 out.append(">") 

788 if tag not in VOID_ELEMENTS: 

789 # We were still in SVG but now we default back into HTML 

790 if tag == "foreignobject": 

791 child_ctx = our_ctx.copy(ns="html") 

792 else: 

793 child_ctx = our_ctx 

794 out.extend( 

795 self._process_tnode(template, child_ctx, child) for child in children 

796 ) 

797 out.append(f"</{endtag}>") 

798 return "".join(out) 

799 

800 def _process_attrs( 

801 self, 

802 template: Template, 

803 last_ctx: ProcessContext, 

804 attrs: tuple[TAttribute, ...], 

805 ) -> str: 

806 """ 

807 Process an element's attributes into a string. 

808 """ 

809 resolved_attrs = _resolve_t_attrs(attrs, template) 

810 if last_ctx.ns == "svg": 

811 attrs_str = serialize_html_attrs( 

812 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) 

813 ) 

814 else: 

815 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) 

816 if attrs_str: 

817 return attrs_str 

818 return "" 

819 

820 def _process_component( 

821 self, 

822 template: Template, 

823 last_ctx: ProcessContext, 

824 attrs: tuple[TAttribute, ...], 

825 start_i_index: int, 

826 end_i_index: int | None, 

827 children_span: TemplateSpan | None, 

828 ) -> str: 

829 """ 

830 Invoke a component and process the result into a string. 

831 """ 

832 children_template = ( 

833 children_span.extract(template) 

834 if children_span is not None 

835 else Template("") 

836 ) 

837 if ( 

838 start_i_index != end_i_index 

839 and end_i_index is not None 

840 and template.interpolations[start_i_index].value 

841 != template.interpolations[end_i_index].value 

842 ): 

843 raise TypeError( 

844 "Component callable in start tag must match component callable in end tag." 

845 ) 

846 component_callable = template.interpolations[start_i_index].value 

847 result_t = self.component_processor_api.process( 

848 template, last_ctx, component_callable, attrs, children_template 

849 ) 

850 if isinstance(result_t, ScopedTemplate): 

851 with result_t.scope.activate(): 

852 return self._process_template(result_t.template, last_ctx) 

853 return self._process_template(result_t, last_ctx) 

854 

855 def _process_raw_texts( 

856 self, 

857 template: Template, 

858 last_ctx: ProcessContext, 

859 content_ref: TemplateRef, 

860 ) -> str: 

861 """ 

862 Process the given content into a string as "raw text". 

863 """ 

864 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS 

865 content = resolve_text_without_recursion( 

866 template, last_ctx.parent_tag, content_ref 

867 ) 

868 if last_ctx.parent_tag == "script": 

869 return self.escape_html_script( 

870 content, 

871 allow_markup=True, 

872 ) 

873 elif last_ctx.parent_tag == "style": 

874 return self.escape_html_style( 

875 content, 

876 allow_markup=True, 

877 ) 

878 else: 

879 raise NotImplementedError( 

880 f"Parent tag {last_ctx.parent_tag} is not supported." 

881 ) 

882 

883 def _process_escapable_raw_texts( 

884 self, 

885 template: Template, 

886 last_ctx: ProcessContext, 

887 content_ref: TemplateRef, 

888 ) -> str: 

889 """ 

890 Process the given content into a string as "escapable raw text". 

891 """ 

892 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS 

893 content = resolve_text_without_recursion( 

894 template, last_ctx.parent_tag, content_ref 

895 ) 

896 return self.escape_html_text(content) 

897 

898 def _process_normal_texts( 

899 self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef 

900 ): 

901 """ 

902 Process the given context into a string as "normal text". 

903 """ 

904 return "".join( 

905 ( 

906 self.escape_html_text(part) 

907 if isinstance(part, str) 

908 else self._process_normal_text(template, last_ctx, t.cast(int, part)) 

909 ) 

910 for part in content_ref 

911 ) 

912 

913 def _process_normal_text( 

914 self, 

915 template: Template, 

916 last_ctx: ProcessContext, 

917 values_index: int, 

918 ) -> str: 

919 """ 

920 Process the value of the interpolation into a string as "normal text". 

921 

922 @NOTE: This is an interpolation that must be formatted to get the value. 

923 """ 

924 value = format_interpolation(template.interpolations[values_index]) 

925 value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast] 

926 return self._process_normal_text_from_value(template, last_ctx, value) 

927 

928 def _process_normal_text_from_value( 

929 self, 

930 template: Template, 

931 last_ctx: ProcessContext, 

932 value: NormalTextInterpolationValue, 

933 ) -> str: 

934 """ 

935 Process a single value into a string as "normal text". 

936 

937 @NOTE: This is an actual value and NOT an interpolation. This is meant to be 

938 used when processing an iterable of values as normal text. 

939 """ 

940 if value is None or isinstance(value, bool): 

941 return "" 

942 elif isinstance(value, str): 

943 # @NOTE: This would apply to Markup() but not to a custom object 

944 # implementing HasHTMLDunder. 

945 return self.escape_html_text(value) 

946 elif isinstance(value, Template): 

947 return self._process_template(value, last_ctx) 

948 elif isinstance(value, Iterable): 

949 return "".join( 

950 self._process_normal_text_from_value(template, last_ctx, v) 

951 for v in value 

952 ) 

953 elif isinstance(value, HasHTMLDunder): 

954 # @NOTE: markupsafe's escape does this for us but we put this in 

955 # here for completeness. 

956 # @NOTE: An actual Markup() would actually pass as a str() but a 

957 # custom object with __html__ might not. 

958 return Markup(value.__html__()) 

959 else: 

960 # @DESIGN: Everything that isn't an object we recognize is 

961 # coerced to a str() and emitted. 

962 return self.escape_html_text(value) 

963 

964 

965def resolve_text_without_recursion( 

966 template: Template, parent_tag: str, content_ref: TemplateRef 

967) -> str: 

968 """ 

969 Resolve the text in the given template without recursing into more structured text. 

970 

971 This can be bypassed by interpolating an exact match with an object with `__html__()`. 

972 

973 A non-exact match is not allowed because we cannot process escaping 

974 across the boundary between other content and the pass-through content. 

975 """ 

976 if content_ref.is_singleton: 

977 value = format_interpolation(template.interpolations[content_ref.i_start]) 

978 value = t.cast(RawTextExactInterpolationValue, value) # ty: ignore[redundant-cast] 

979 if value is None or isinstance(value, bool): 

980 return "" 

981 elif isinstance(value, str): 

982 return value 

983 elif isinstance(value, HasHTMLDunder): 

984 # @DESIGN: We could also force callers to use `:safe` to trigger 

985 # the interpolation in this special case. 

986 return Markup(value.__html__()) 

987 elif isinstance(value, (Template, Iterable)): 

988 raise ValueError( 

989 f"Recursive includes are not supported within {parent_tag}" 

990 ) 

991 else: 

992 return str(value) 

993 else: 

994 text = [] 

995 for part in content_ref: 

996 if isinstance(part, str): 

997 if part: 

998 text.append(part) 

999 continue 

1000 value = format_interpolation(template.interpolations[part]) 

1001 value = t.cast(RawTextInexactInterpolationValue, value) # ty: ignore[redundant-cast] 

1002 if value is None or isinstance(value, bool): 

1003 continue 

1004 elif ( 

1005 type(value) is str 

1006 ): # type() check to avoid subclasses, probably something smarter here 

1007 if value: 

1008 text.append(value) 

1009 elif not isinstance(value, str) and isinstance(value, (Template, Iterable)): 

1010 raise ValueError( 

1011 f"Recursive includes are not supported within {parent_tag}" 

1012 ) 

1013 elif isinstance(value, HasHTMLDunder): 

1014 raise ValueError( 

1015 f"Non-exact trusted interpolations are not supported within {parent_tag}" 

1016 ) 

1017 else: 

1018 value_str = str(value) 

1019 if value_str: 

1020 text.append(value_str) 

1021 return "".join(text) 

1022 

1023 

1024def _make_default_template_processor( 

1025 parser_api: ITemplateParserProxy | None = None, 

1026) -> ITemplateProcessor: 

1027 """ 

1028 Wrap our default options but allow parser api to change for testing. 

1029 """ 

1030 return TemplateProcessor( 

1031 parser_api=CachedTemplateParserProxy() if parser_api is None else parser_api, 

1032 slash_void=True, 

1033 uppercase_doctype=True, 

1034 ) 

1035 

1036 

1037_default_template_processor_api: ITemplateProcessor = _make_default_template_processor() 

1038 

1039 

1040# -------------------------------------------------------------------------- 

1041# Public API 

1042# -------------------------------------------------------------------------- 

1043 

1044 

1045def html(template: Template, assume_ctx: ProcessContext | None = None) -> str: 

1046 """Parse an HTML t-string, substitute values, and return a string of HTML.""" 

1047 if assume_ctx is None: 

1048 assume_ctx = ProcessContext() 

1049 return _default_template_processor_api.process(template, assume_ctx) 

1050 

1051 

1052def svg(template: Template, assume_ctx: ProcessContext | None = None) -> str: 

1053 """Parse a standalone SVG fragment and return a string of HTML. 

1054 

1055 Use when the template does not contain an ``<svg>`` wrapper element. 

1056 Tag and attribute case-fixing (e.g. ``clipPath``, ``viewBox``) are applied 

1057 from the root, exactly as they would be inside ``html(t"<svg>...</svg>")``. 

1058 

1059 When the template does contain ``<svg>``, use ``html()`` — the SVG context 

1060 is detected automatically. 

1061 """ 

1062 if assume_ctx is None: 

1063 assume_ctx = ProcessContext(ns="svg") 

1064 return html(template, assume_ctx=assume_ctx)