Coverage for tdom / processor.py: 98%

432 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-03 21:23 +0000

1import typing as t 

2from collections.abc import Callable, Iterable, 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 ( 

33 HTMLAttribute, 

34 TAttribute, 

35 TComment, 

36 TComponent, 

37 TDocumentType, 

38 TElement, 

39 TemplateParser, 

40 TFragment, 

41 TInterpolatedAttribute, 

42 TLiteralAttribute, 

43 TNode, 

44 TSpreadAttribute, 

45 TTemplatedAttribute, 

46 TText, 

47) 

48from .protocols import HasHTMLDunder 

49from .template_utils import TemplateRef 

50from .utils import CachableTemplate, LastUpdatedOrderedDict 

51 

52type Attribute = tuple[str, object] 

53type AttributesDict = dict[str, object] 

54 

55 

56# -------------------------------------------------------------------------- 

57# Custom formatting for the processor 

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

59 

60 

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

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

63 assert format_spec == "safe" 

64 return Markup(value) 

65 

66 

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

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

69 assert format_spec == "unsafe" 

70 return str(value) 

71 

72 

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

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

75 assert format_spec == "callback" 

76 return value() 

77 

78 

79CUSTOM_FORMATTERS = ( 

80 ("safe", _format_safe), 

81 ("unsafe", _format_unsafe), 

82 ("callback", _format_callback), 

83) 

84 

85 

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

87 return base_format_interpolation( 

88 interpolation, 

89 formatters=CUSTOM_FORMATTERS, 

90 ) 

91 

92 

93# -------------------------------------------------------------------------- 

94# Placeholder Substitution 

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

96 

97 

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

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

100 if value is None: 

101 return 

102 elif isinstance(value, dict): 

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

104 if sub_v is True: 

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

106 elif sub_v is False: 

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

108 elif sub_v is None: 

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

110 else: 

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

112 else: 

113 raise TypeError( 

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

115 ) 

116 

117 

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

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

120 if value is None: 

121 return 

122 elif isinstance(value, dict): 

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

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

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

126 else: 

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

128 else: 

129 raise TypeError( 

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

131 ) 

132 

133 

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

135 """ 

136 Substitute a spread attribute based on the interpolated value. 

137 

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

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

140 The value must be a dict or iterable of key-value pairs. 

141 """ 

142 if value is None: 

143 return 

144 elif isinstance(value, dict): 

145 yield from value.items() 

146 else: 

147 raise TypeError( 

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

149 ) 

150 

151 

152ATTR_EXPANDERS = { 

153 "data": _expand_data_attr, 

154 "aria": _expand_aria_attr, 

155} 

156 

157 

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

159 """ 

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

161 """ 

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

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

164 for prop in props: 

165 if prop: 

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

167 if len(prop_parts) != 2: 

168 raise ValueError( 

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

170 ) 

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

172 return styles 

173 

174 

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

176 """ 

177 Initialize the style accumulator. 

178 """ 

179 match old_value: 

180 case str(): 

181 styles = { 

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

183 } 

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

185 styles = {} 

186 case _: 

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

188 return StyleAccumulator(styles=styles) 

189 

190 

191@dataclass 

192class StyleAccumulator: 

193 styles: dict[str, str | None] 

194 

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

196 """ 

197 Merge in an interpolated style value. 

198 """ 

199 match value: 

200 case str(): 

201 self.styles.update( 

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

203 ) 

204 case dict(): 

205 self.styles.update( 

206 { 

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

208 for pn, pv in value.items() 

209 } 

210 ) 

211 case None: 

212 pass 

213 case _: 

214 raise TypeError( 

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

216 ) 

217 

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

219 """ 

220 Serialize the special style value back into a string. 

221 

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

223 """ 

224 style_value = "; ".join( 

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

226 ) 

227 return style_value if style_value else None 

228 

229 

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

231 """ 

232 Initialize the class accumulator. 

233 """ 

234 match old_value: 

235 case str(): 

236 toggled_classes = {cn: True for cn in old_value.split()} 

237 case True: 

238 toggled_classes = {} 

239 case _: 

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

241 return ClassAccumulator(toggled_classes=toggled_classes) 

242 

243 

244@dataclass 

245class ClassAccumulator: 

246 toggled_classes: dict[str, bool] 

247 

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

249 """ 

250 Merge in an interpolated class value. 

251 """ 

252 if isinstance(value, dict): 

253 self.toggled_classes.update( 

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

255 ) 

256 else: 

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

258 items = value[:] 

259 else: 

260 items = (value,) 

261 for item in items: 

262 match item: 

263 case str(): 

264 self.toggled_classes.update({cn: True for cn in item.split()}) 

265 case None: 

266 pass 

267 case _: 

268 if item == value: 

269 raise TypeError( 

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

271 ) 

272 else: 

273 raise TypeError( 

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

275 ) 

276 

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

278 """ 

279 Serialize the special class value back into a string. 

280 

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

282 """ 

283 class_value = " ".join( 

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

285 ) 

286 return class_value if class_value else None 

287 

288 

289ATTR_ACCUMULATOR_MAKERS = { 

290 "class": make_class_accumulator, 

291 "style": make_style_accumulator, 

292} 

293 

294 

295type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator 

296 

297 

298def _resolve_t_attrs( 

299 attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] 

300) -> 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 new_attrs: AttributesDict = LastUpdatedOrderedDict() 

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

309 for attr in attrs: 

310 match attr: 

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

312 attr_value = True if value is None else value 

313 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs: 

314 if name not in attr_accs: 

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

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

317 else: 

318 new_attrs[name] = attr_value 

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

320 interpolation = interpolations[i_index] 

321 attr_value = format_interpolation(interpolation) 

322 if name in ATTR_ACCUMULATOR_MAKERS: 

323 if name not in attr_accs: 

324 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

325 new_attrs.get(name, True) 

326 ) 

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

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

329 for sub_k, sub_v in expander(attr_value): 

330 new_attrs[sub_k] = sub_v 

331 else: 

332 new_attrs[name] = attr_value 

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

334 attr_t = ref.resolve(interpolations) 

335 attr_value = format_template(attr_t) 

336 if name in ATTR_ACCUMULATOR_MAKERS: 

337 if name not in attr_accs: 

338 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

339 new_attrs.get(name, True) 

340 ) 

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

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

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

344 else: 

345 new_attrs[name] = attr_value 

346 case TSpreadAttribute(i_index=i_index): 

347 interpolation = interpolations[i_index] 

348 spread_value = format_interpolation(interpolation) 

349 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

350 if sub_k in ATTR_ACCUMULATOR_MAKERS: 

351 if sub_k not in attr_accs: 

352 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k]( 

353 new_attrs.get(sub_k, True) 

354 ) 

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

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

357 for exp_k, exp_v in expander(sub_v): 

358 new_attrs[exp_k] = exp_v 

359 else: 

360 new_attrs[sub_k] = sub_v 

361 case _: 

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

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

364 new_attrs[acc_name] = acc.to_value() 

365 return new_attrs 

366 

367 

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

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

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

371 match value: 

372 case True: 

373 yield key, None 

374 case False | None: 

375 pass 

376 case _: 

377 yield key, str(value) 

378 

379 

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

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

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

383 

384 

385def _prep_component_kwargs( 

386 callable_info: CallableInfo, 

387 attrs: AttributesDict, 

388 children: Template, 

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

390 raise_on_requires_positional=True, 

391 raise_on_missing=True, 

392) -> AttributesDict: 

393 """ 

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

395 

396 `provided_attrs`: 

397 These can be used by extensions that want to provide 

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

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

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

401 @NOTE: These will be injected into any component with `**kwargs` 

402 in their signature unless provided already by `attrs`. 

403 

404 `raise_on_requires_positional`: 

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

406 positional arguments which we cannot fulfill normally. 

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

408 the arguments after this call. 

409 

410 `raise_on_missing`: 

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

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

413 raise an exception whose cause might not be clear. 

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

415 the arguments after this call. 

416 """ 

417 

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

419 if raise_on_requires_positional and callable_info.requires_positional: 

420 raise TypeError( 

421 "Component callables cannot have required positional arguments." 

422 ) 

423 

424 kwargs: AttributesDict = {} 

425 

426 # Add all supported attributes 

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

428 snake_name = _kebab_to_snake(attr_name) 

429 if snake_name in callable_info.named_params or callable_info.kwargs: 

430 kwargs[snake_name] = attr_value 

431 

432 if "children" in callable_info.named_params or callable_info.kwargs: 

433 kwargs["children"] = children 

434 

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

436 for pattr_name, pattr_value in provided_attrs: 

437 if pattr_name not in kwargs and ( 

438 pattr_name in callable_info.named_params or callable_info.kwargs 

439 ): 

440 kwargs[pattr_name] = pattr_value 

441 

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

443 if raise_on_missing: 

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

445 if missing: 

446 raise TypeError( 

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

448 ) 

449 

450 return kwargs 

451 

452 

453def serialize_html_attrs( 

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

455) -> str: 

456 return "".join( 

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

458 ) 

459 

460 

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

462 """ 

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

464 """ 

465 for k, v in html_attrs: 

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

467 

468 

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

470class ProcessContext: 

471 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT 

472 ns: str = "html" 

473 

474 def copy( 

475 self, 

476 ns: str | None = None, 

477 parent_tag: str | None = None, 

478 ) -> ProcessContext: 

479 return ProcessContext( 

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

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

482 ) 

483 

484 

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

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

487type ComponentCallable = FunctionComponent | FactoryComponent 

488type ComponentObject = Callable[[], Template] 

489 

490 

491type NormalTextInterpolationValue = ( 

492 None 

493 | bool # to support `showValue and value` idiom 

494 | str 

495 | HasHTMLDunder 

496 | Template 

497 | Iterable[NormalTextInterpolationValue] 

498 | object 

499) 

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

501type RawTextExactInterpolationValue = ( 

502 None 

503 | bool # to support `showValue and value` idiom 

504 | str 

505 | HasHTMLDunder 

506 | object 

507) 

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

509type RawTextInexactInterpolationValue = ( 

510 None 

511 | bool # to support `showValue and value` idiom 

512 | str 

513 | object 

514) 

515 

516 

517class ITemplateParserProxy(t.Protocol): 

518 def to_tnode(self, template: Template) -> TNode: ... 

519 

520 

521@dataclass(frozen=True) 

522class TemplateParserProxy(ITemplateParserProxy): 

523 def to_tnode(self, template: Template) -> TNode: 

524 return TemplateParser.parse(template) 

525 

526 

527@dataclass(frozen=True) 

528class CachedTemplateParserProxy(TemplateParserProxy): 

529 @lru_cache(512) # noqa: B019 

530 def _to_tnode(self, ct: CachableTemplate) -> TNode: 

531 return super().to_tnode(ct.template) 

532 

533 def to_tnode(self, template: Template) -> TNode: 

534 return self._to_tnode(CachableTemplate(template)) 

535 

536 

537class IComponentProcessor(t.Protocol): 

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

539 

540 def process( 

541 self, 

542 template: Template, 

543 last_ctx: ProcessContext, 

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

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

546 component_template: Template, 

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

548 ) -> Template: 

549 """ 

550 Process available component details into a Template. 

551 """ 

552 ... 

553 

554 

555class ComponentProcessor(IComponentProcessor): 

556 """ 

557 Default component processor. 

558 """ 

559 

560 def process( 

561 self, 

562 template: Template, 

563 last_ctx: ProcessContext, 

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

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

566 component_template: Template, 

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

568 ) -> Template: 

569 """ 

570 Process available component details into a Template. 

571 

572 There are two general "styles" supported: 

573 

574 1. FunctionComponent 

575 

576 Calling `component_callable` with the prepared kwargs should 

577 return a `Template`. 

578 

579 The primary purpose of this style is to support 

580 using a normal function as a component. 

581 

582 2. FactoryComponent 

583 

584 Calling `component_callable` with the prepared kwargs should 

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

586 return a `Template`. 

587 

588 The primary purpose of this style is to support 

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

590 component. 

591 """ 

592 if not callable(component_callable): 

593 raise TypeError( 

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

595 ) 

596 kwargs = _prep_component_kwargs( 

597 get_callable_info(component_callable), 

598 _resolve_t_attrs(attrs, template.interpolations), 

599 children=component_template, 

600 provided_attrs=provided_attrs, 

601 raise_on_requires_positional=True, 

602 raise_on_missing=True, 

603 ) 

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

605 if isinstance(res1, Template): 

606 return res1 

607 elif callable(res1): 

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

609 if isinstance(res2, Template): 

610 return res2 

611 else: 

612 raise TypeError( 

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

614 ) 

615 else: 

616 raise TypeError( 

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

618 ) 

619 

620 

621class ITemplateProcessor(t.Protocol): 

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

623 

624 

625@dataclass(frozen=True) 

626class TemplateProcessor(ITemplateProcessor): 

627 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy) 

628 

629 component_processor_api: IComponentProcessor = field( 

630 default_factory=ComponentProcessor 

631 ) 

632 

633 escape_html_text: Callable = default_escape_html_text 

634 

635 escape_html_comment: Callable = default_escape_html_comment 

636 

637 escape_html_script: Callable = default_escape_html_script 

638 

639 escape_html_style: Callable = default_escape_html_style 

640 

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

642 

643 uppercase_doctype: bool = False # DOCTYPE vs doctype 

644 

645 def process( 

646 self, 

647 root_template: Template, 

648 assume_ctx: ProcessContext, 

649 ) -> str: 

650 """ 

651 Process a TDOM compatible template into a string. 

652 """ 

653 return self._process_template(root_template, assume_ctx) 

654 

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

656 root = self.parser_api.to_tnode(template) 

657 return self._process_tnode(template, last_ctx, root) 

658 

659 def _process_tnode( 

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

661 ) -> str: 

662 """ 

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

664 """ 

665 match tnode: 

666 case TDocumentType(text): 

667 return self._process_document_type(last_ctx, text) 

668 case TComment(ref): 

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

670 case TFragment(children): 

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

672 case TComponent(start_i_index, end_i_index, attrs, children): 

673 return self._process_component( 

674 template, last_ctx, attrs, start_i_index, end_i_index 

675 ) 

676 case TElement(tag, attrs, children): 

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

678 case TText(ref): 

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

680 case _: 

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

682 

683 def _process_document_type( 

684 self, 

685 last_ctx: ProcessContext, 

686 text: str, 

687 ) -> str: 

688 if last_ctx.ns != "html": 

689 # Nit 

690 raise ValueError( 

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

692 ) 

693 if self.uppercase_doctype: 

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

695 else: 

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

697 

698 def _process_fragment( 

699 self, 

700 template: Template, 

701 last_ctx: ProcessContext, 

702 children: Iterable[TNode], 

703 ) -> str: 

704 return "".join( 

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

706 ) 

707 

708 def _process_texts( 

709 self, 

710 template: Template, 

711 last_ctx: ProcessContext, 

712 ref: TemplateRef, 

713 ) -> str: 

714 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: 

715 # Must be handled all at once. 

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

717 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: 

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

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

720 else: 

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

722 

723 def _process_comment( 

724 self, 

725 template: Template, 

726 last_ctx: ProcessContext, 

727 content_ref: TemplateRef, 

728 ) -> str: 

729 """ 

730 Process a comment into a string. 

731 """ 

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

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

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

735 

736 def _process_element( 

737 self, 

738 template: Template, 

739 last_ctx: ProcessContext, 

740 tag: str, 

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

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

743 ) -> str: 

744 out: list[str] = [] 

745 if tag == "svg": 

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

747 elif tag == "math": 

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

749 else: 

750 our_ctx = last_ctx.copy(parent_tag=tag) 

751 if our_ctx.ns == "svg": 

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

753 else: 

754 starttag = endtag = tag 

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

756 if attrs: 

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

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

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

760 if self.slash_void and tag in VOID_ELEMENTS: 

761 out.append(" />") 

762 else: 

763 out.append(">") 

764 if tag not in VOID_ELEMENTS: 

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

766 if tag == "foreignobject": 

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

768 else: 

769 child_ctx = our_ctx 

770 out.extend( 

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

772 ) 

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

774 return "".join(out) 

775 

776 def _process_attrs( 

777 self, 

778 template: Template, 

779 last_ctx: ProcessContext, 

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

781 ) -> str: 

782 """ 

783 Process an element's attributes into a string. 

784 """ 

785 resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) 

786 if last_ctx.ns == "svg": 

787 attrs_str = serialize_html_attrs( 

788 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) 

789 ) 

790 else: 

791 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) 

792 if attrs_str: 

793 return attrs_str 

794 return "" 

795 

796 def _extract_component_template( 

797 self, 

798 template: Template, 

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

800 start_i_index: int, 

801 end_i_index: int | None, 

802 check_callables: bool = True, 

803 ) -> Template: 

804 body_start_s_index = ( 

805 start_i_index 

806 + 1 

807 + len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)]) 

808 ) 

809 if start_i_index != end_i_index and end_i_index is not None: 

810 # @TODO: We should do this during parsing. 

811 if ( 

812 check_callables 

813 and template.interpolations[start_i_index].value 

814 != template.interpolations[end_i_index].value 

815 ): 

816 raise TypeError( 

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

818 ) 

819 return extract_embedded_template(template, body_start_s_index, end_i_index) 

820 else: 

821 return t"" 

822 

823 def _process_component( 

824 self, 

825 template: Template, 

826 last_ctx: ProcessContext, 

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

828 start_i_index: int, 

829 end_i_index: int | None, 

830 ) -> str: 

831 """ 

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

833 """ 

834 children_template = self._extract_component_template( 

835 template, attrs, start_i_index, end_i_index, check_callables=True 

836 ) 

837 component_callable = template.interpolations[start_i_index].value 

838 result_t = self.component_processor_api.process( 

839 template, last_ctx, component_callable, attrs, children_template 

840 ) 

841 return self._process_template(result_t, last_ctx) 

842 

843 def _process_raw_texts( 

844 self, 

845 template: Template, 

846 last_ctx: ProcessContext, 

847 content_ref: TemplateRef, 

848 ) -> str: 

849 """ 

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

851 """ 

852 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS 

853 content = resolve_text_without_recursion( 

854 template, last_ctx.parent_tag, content_ref 

855 ) 

856 if last_ctx.parent_tag == "script": 

857 return self.escape_html_script( 

858 content, 

859 allow_markup=True, 

860 ) 

861 elif last_ctx.parent_tag == "style": 

862 return self.escape_html_style( 

863 content, 

864 allow_markup=True, 

865 ) 

866 else: 

867 raise NotImplementedError( 

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

869 ) 

870 

871 def _process_escapable_raw_texts( 

872 self, 

873 template: Template, 

874 last_ctx: ProcessContext, 

875 content_ref: TemplateRef, 

876 ) -> str: 

877 """ 

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

879 """ 

880 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS 

881 content = resolve_text_without_recursion( 

882 template, last_ctx.parent_tag, content_ref 

883 ) 

884 return self.escape_html_text(content) 

885 

886 def _process_normal_texts( 

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

888 ): 

889 """ 

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

891 """ 

892 return "".join( 

893 ( 

894 self.escape_html_text(part) 

895 if isinstance(part, str) 

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

897 ) 

898 for part in content_ref 

899 ) 

900 

901 def _process_normal_text( 

902 self, 

903 template: Template, 

904 last_ctx: ProcessContext, 

905 values_index: int, 

906 ) -> str: 

907 """ 

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

909 

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

911 """ 

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

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

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

915 

916 def _process_normal_text_from_value( 

917 self, 

918 template: Template, 

919 last_ctx: ProcessContext, 

920 value: NormalTextInterpolationValue, 

921 ) -> str: 

922 """ 

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

924 

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

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

927 """ 

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

929 return "" 

930 elif isinstance(value, str): 

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

932 # implementing HasHTMLDunder. 

933 return self.escape_html_text(value) 

934 elif isinstance(value, Template): 

935 return self._process_template(value, last_ctx) 

936 elif isinstance(value, Iterable): 

937 return "".join( 

938 self._process_normal_text_from_value(template, last_ctx, v) 

939 for v in value 

940 ) 

941 elif isinstance(value, HasHTMLDunder): 

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

943 # here for completeness. 

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

945 # custom object with __html__ might not. 

946 return Markup(value.__html__()) 

947 else: 

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

949 # coerced to a str() and emitted. 

950 return self.escape_html_text(value) 

951 

952 

953def resolve_text_without_recursion( 

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

955) -> str: 

956 """ 

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

958 

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

960 

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

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

963 """ 

964 if content_ref.is_singleton: 

965 value = format_interpolation(template.interpolations[content_ref.i_indexes[0]]) 

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

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

968 return "" 

969 elif isinstance(value, str): 

970 return value 

971 elif isinstance(value, HasHTMLDunder): 

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

973 # the interpolation in this special case. 

974 return Markup(value.__html__()) 

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

976 raise ValueError( 

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

978 ) 

979 else: 

980 return str(value) 

981 else: 

982 text = [] 

983 for part in content_ref: 

984 if isinstance(part, str): 

985 if part: 

986 text.append(part) 

987 continue 

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

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

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

991 continue 

992 elif ( 

993 type(value) is str 

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

995 if value: 

996 text.append(value) 

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

998 raise ValueError( 

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

1000 ) 

1001 elif isinstance(value, HasHTMLDunder): 

1002 raise ValueError( 

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

1004 ) 

1005 else: 

1006 value_str = str(value) 

1007 if value_str: 

1008 text.append(value_str) 

1009 return "".join(text) 

1010 

1011 

1012def extract_embedded_template( 

1013 template: Template, body_start_s_index: int, end_i_index: int 

1014) -> Template: 

1015 """ 

1016 Extract the template parts exclusively from start tag to end tag. 

1017 

1018 Note that interpolations INSIDE the start tag make this more complex 

1019 than just "the `s_index` after the component callable's `i_index`". 

1020 

1021 Example: 

1022 ```python 

1023 template = ( 

1024 t'<{comp} attr={attr}>' 

1025 t'<div>{content} <span>{footer}</span></div>' 

1026 t'</{comp}>' 

1027 ) 

1028 assert extract_children_template(template, 2, 4) == ( 

1029 t'<div>{content} <span>{footer}</span></div>' 

1030 ) 

1031 starttag = t'<{comp} attr={attr}>' 

1032 endtag = t'</{comp}>' 

1033 assert template == starttag + extract_children_template(template, 2, 4) + endtag 

1034 ``` 

1035 @DESIGN: "There must be a better way." 

1036 """ 

1037 # Copy the parts out of the containing template. 

1038 index = body_start_s_index 

1039 last_s_index = end_i_index 

1040 parts = [] 

1041 while index <= last_s_index: 

1042 parts.append(template.strings[index]) 

1043 if index != last_s_index: 

1044 parts.append(template.interpolations[index]) 

1045 index += 1 

1046 # Now trim the first part to the end of the opening tag. 

1047 parts[0] = parts[0][parts[0].find(">") + 1 :] 

1048 # Now trim the last part (could also be the first) to the start of the closing tag. 

1049 parts[-1] = parts[-1][: parts[-1].rfind("<")] 

1050 return Template(*parts) 

1051 

1052 

1053def _make_default_template_processor( 

1054 parser_api: ITemplateParserProxy | None = None, 

1055) -> ITemplateProcessor: 

1056 """ 

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

1058 """ 

1059 return TemplateProcessor( 

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

1061 slash_void=True, 

1062 uppercase_doctype=True, 

1063 ) 

1064 

1065 

1066_default_template_processor_api: ITemplateProcessor = _make_default_template_processor() 

1067 

1068 

1069# -------------------------------------------------------------------------- 

1070# Public API 

1071# -------------------------------------------------------------------------- 

1072 

1073 

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

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

1076 if assume_ctx is None: 

1077 assume_ctx = ProcessContext() 

1078 return _default_template_processor_api.process(template, assume_ctx) 

1079 

1080 

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

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

1083 

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

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

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

1087 

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

1089 is detected automatically. 

1090 """ 

1091 if assume_ctx is None: 

1092 assume_ctx = ProcessContext(ns="svg") 

1093 return html(template, assume_ctx=assume_ctx)