Coverage for tdom/processor.py: 98%

423 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 00:48 +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 ( 

33 HTMLAttribute, 

34 TemplateParser, 

35) 

36from .protocols import HasHTMLDunder 

37from .scope import ScopedTemplate 

38from .template_utils import TemplateRef 

39from .tnodes import ( 

40 TAttribute, 

41 TComment, 

42 TComponent, 

43 TDocumentType, 

44 TElement, 

45 TFragment, 

46 TInterpolatedAttribute, 

47 TLiteralAttribute, 

48 TNode, 

49 TSpreadAttribute, 

50 TTemplatedAttribute, 

51 TText, 

52) 

53from .utils import CachableTemplate, LastUpdatedOrderedDict 

54 

55type Attribute = tuple[str, object] 

56type AttributesDict = dict[str, object] 

57 

58 

59# -------------------------------------------------------------------------- 

60# Custom formatting for the processor 

61# -------------------------------------------------------------------------- 

62 

63 

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

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

66 assert format_spec == "safe" 

67 return Markup(value) 

68 

69 

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

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

72 assert format_spec == "unsafe" 

73 return str(value) 

74 

75 

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

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

78 assert format_spec == "callback" 

79 return value() 

80 

81 

82CUSTOM_FORMATTERS = ( 

83 ("safe", _format_safe), 

84 ("unsafe", _format_unsafe), 

85 ("callback", _format_callback), 

86) 

87 

88 

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

90 return base_format_interpolation( 

91 interpolation, 

92 formatters=CUSTOM_FORMATTERS, 

93 ) 

94 

95 

96# -------------------------------------------------------------------------- 

97# Placeholder Substitution 

98# -------------------------------------------------------------------------- 

99 

100 

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

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

103 if value is None: 

104 return 

105 elif isinstance(value, Mapping): 

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

107 if sub_v is True: 

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

109 elif sub_v is False: 

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

111 elif sub_v is None: 

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

113 else: 

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

115 else: 

116 raise TypeError( 

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

118 ) 

119 

120 

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

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

123 if value is None: 

124 return 

125 elif isinstance(value, Mapping): 

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

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

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

129 else: 

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

131 else: 

132 raise TypeError( 

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

134 ) 

135 

136 

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

138 """ 

139 Substitute a spread attribute based on the interpolated value. 

140 

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

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

143 The value must be a Mapping. 

144 """ 

145 if value is None: 

146 return 

147 elif isinstance(value, Mapping): 

148 yield from value.items() 

149 else: 

150 raise TypeError( 

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

152 ) 

153 

154 

155ATTR_EXPANDERS = { 

156 "data": _expand_data_attr, 

157 "aria": _expand_aria_attr, 

158} 

159 

160 

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

162 """ 

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

164 """ 

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

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

167 for prop in props: 

168 if prop: 

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

170 if len(prop_parts) != 2: 

171 raise ValueError( 

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

173 ) 

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

175 return styles 

176 

177 

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

179 """ 

180 Initialize the style accumulator. 

181 """ 

182 match old_value: 

183 case str(): 

184 styles = { 

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

186 } 

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

188 styles = {} 

189 case _: 

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

191 return StyleAccumulator(styles=styles) 

192 

193 

194@dataclass 

195class StyleAccumulator: 

196 styles: dict[str, str | None] 

197 

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

199 """ 

200 Merge in an interpolated style value. 

201 """ 

202 match value: 

203 case str(): 

204 self.styles.update( 

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

206 ) 

207 case Mapping(): 

208 self.styles.update( 

209 { 

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

211 for pn, pv in value.items() 

212 } 

213 ) 

214 case None: 

215 pass 

216 case _: 

217 raise TypeError( 

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

219 ) 

220 

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

222 """ 

223 Serialize the special style value back into a string. 

224 

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

226 """ 

227 style_value = "; ".join( 

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

229 ) 

230 return style_value if style_value else None 

231 

232 

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

234 """ 

235 Initialize the class accumulator. 

236 """ 

237 match old_value: 

238 case str(): 

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

240 case True: 

241 toggled_classes = {} 

242 case _: 

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

244 return ClassAccumulator(toggled_classes=toggled_classes) 

245 

246 

247@dataclass 

248class ClassAccumulator: 

249 toggled_classes: dict[str, bool] 

250 

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

252 """ 

253 Merge in an interpolated class value. 

254 """ 

255 if isinstance(value, Mapping): 

256 self.toggled_classes.update( 

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

258 ) 

259 else: 

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

261 items = value[:] 

262 else: 

263 items = (value,) 

264 for item in items: 

265 match item: 

266 case str(): 

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

268 case None: 

269 pass 

270 case _: 

271 if item == value: 

272 raise TypeError( 

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

274 ) 

275 else: 

276 raise TypeError( 

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

278 ) 

279 

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

281 """ 

282 Serialize the special class value back into a string. 

283 

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

285 """ 

286 class_value = " ".join( 

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

288 ) 

289 return class_value if class_value else None 

290 

291 

292ATTR_ACCUMULATOR_MAKERS = { 

293 "class": make_class_accumulator, 

294 "style": make_style_accumulator, 

295} 

296 

297 

298type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator 

299 

300 

301def _resolve_t_attrs( 

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

303) -> AttributesDict: 

304 """ 

305 Replace placeholder values in attributes with their interpolated values. 

306 

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

308 in a later step. 

309 

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

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

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

313 """ 

314 new_attrs: AttributesDict = LastUpdatedOrderedDict() 

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

316 for attr in attrs: 

317 match attr: 

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

319 attr_value = True if value is None else value 

320 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs: 

321 if name not in attr_accs: 

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

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

324 else: 

325 new_attrs[name] = attr_value 

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

327 interpolation = interpolations[i_index] 

328 attr_value = format_interpolation(interpolation) 

329 if name in ATTR_ACCUMULATOR_MAKERS: 

330 if name not in attr_accs: 

331 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

332 new_attrs.get(name, True) 

333 ) 

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

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

336 for sub_k, sub_v in expander(attr_value): 

337 new_attrs[sub_k] = sub_v 

338 else: 

339 new_attrs[name] = attr_value 

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

341 attr_t = ref.resolve(interpolations) 

342 attr_value = format_template(attr_t) 

343 if name in ATTR_ACCUMULATOR_MAKERS: 

344 if name not in attr_accs: 

345 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

346 new_attrs.get(name, True) 

347 ) 

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

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

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

351 else: 

352 new_attrs[name] = attr_value 

353 case TSpreadAttribute(i_index=i_index): 

354 interpolation = interpolations[i_index] 

355 spread_value = format_interpolation(interpolation) 

356 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

357 if sub_k in ATTR_ACCUMULATOR_MAKERS: 

358 if sub_k not in attr_accs: 

359 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k]( 

360 new_attrs.get(sub_k, True) 

361 ) 

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

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

364 for exp_k, exp_v in expander(sub_v): 

365 new_attrs[exp_k] = exp_v 

366 else: 

367 new_attrs[sub_k] = sub_v 

368 case _: 

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

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

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

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

373 return new_attrs 

374 

375 

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

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

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

379 match value: 

380 case True: 

381 yield key, None 

382 case False | None: 

383 pass 

384 case _: 

385 yield key, str(value) 

386 

387 

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

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

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

391 

392 

393def _prep_component_kwargs( 

394 callable_info: CallableInfo, 

395 attrs: AttributesDict, 

396 children: Template, 

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

398 raise_on_requires_positional=True, 

399 raise_on_missing=True, 

400) -> AttributesDict: 

401 """ 

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

403 

404 `provided_attrs`: 

405 These can be used by extensions that want to provide 

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

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

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

409 

410 `raise_on_requires_positional`: 

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

412 positional arguments which we cannot fulfill normally. 

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

414 the arguments after this call. 

415 

416 `raise_on_missing`: 

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

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

419 raise an exception whose cause might not be clear. 

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

421 the arguments after this call. 

422 """ 

423 

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

425 if raise_on_requires_positional and callable_info.requires_positional: 

426 raise TypeError( 

427 "Component callables cannot have required positional arguments." 

428 ) 

429 

430 kwargs: AttributesDict = {} 

431 

432 # Add all supported attributes 

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

434 snake_name = _kebab_to_snake(attr_name) 

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

436 kwargs[snake_name] = attr_value 

437 else: 

438 raise ValueError(f"Unexpected attribute {snake_name}.") 

439 

440 if "children" in kwargs: 

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

442 

443 if "children" in callable_info.named_params: 

444 kwargs["children"] = children 

445 

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

447 for pattr_name, pattr_value in provided_attrs: 

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

449 kwargs[pattr_name] = pattr_value 

450 

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

452 if raise_on_missing: 

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

454 if missing: 

455 raise TypeError( 

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

457 ) 

458 

459 return kwargs 

460 

461 

462def serialize_html_attrs( 

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

464) -> str: 

465 return "".join( 

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

467 ) 

468 

469 

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

471 """ 

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

473 """ 

474 for k, v in html_attrs: 

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

476 

477 

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

479class ProcessContext: 

480 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT 

481 ns: str = "html" 

482 

483 def copy( 

484 self, 

485 ns: str | None = None, 

486 parent_tag: str | None = None, 

487 ) -> ProcessContext: 

488 return ProcessContext( 

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

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

491 ) 

492 

493 

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

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

496type ComponentCallable = FunctionComponent | FactoryComponent 

497type ComponentObject = Callable[[], Template] 

498 

499 

500type NormalTextInterpolationValue = ( 

501 None 

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

503 | str 

504 | HasHTMLDunder 

505 | Template 

506 | Iterable[NormalTextInterpolationValue] 

507 | object 

508) 

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

510type RawTextExactInterpolationValue = ( 

511 None 

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

513 | str 

514 | HasHTMLDunder 

515 | object 

516) 

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

518type RawTextInexactInterpolationValue = ( 

519 None 

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

521 | str 

522 | object 

523) 

524 

525 

526class ITemplateParserProxy(t.Protocol): 

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

528 

529 

530@dataclass(frozen=True) 

531class TemplateParserProxy(ITemplateParserProxy): 

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

533 return TemplateParser.parse(template) 

534 

535 

536@dataclass(frozen=True) 

537class CachedTemplateParserProxy(TemplateParserProxy): 

538 @lru_cache(512) # noqa: B019 

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

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

541 

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

543 return self._to_tnode(CachableTemplate(template)) 

544 

545 

546class IComponentProcessor(t.Protocol): 

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

548 

549 def process( 

550 self, 

551 template: Template, 

552 last_ctx: ProcessContext, 

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

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

555 component_template: Template, 

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

557 ) -> Template | ScopedTemplate: 

558 """ 

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

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

561 """ 

562 ... 

563 

564 

565class ComponentProcessor(IComponentProcessor): 

566 """ 

567 Default component processor. 

568 """ 

569 

570 def process( 

571 self, 

572 template: Template, 

573 last_ctx: ProcessContext, 

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

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

576 component_template: Template, 

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

578 ) -> Template | ScopedTemplate: 

579 """ 

580 Process available component details into a Template. 

581 

582 Two general "styles" are supported: 

583 

584 1. FunctionComponent 

585 

586 Calling `component_callable` with the prepared kwargs should 

587 return a `Template`. 

588 

589 The primary purpose of this style is to support 

590 using a normal function as a component. 

591 

592 2. FactoryComponent 

593 

594 Calling `component_callable` with the prepared kwargs should 

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

596 return a `Template`. 

597 

598 The primary purpose of this style is to support 

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

600 component. 

601 

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

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

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

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

606 won't construct one directly. 

607 """ 

608 if not callable(component_callable): 

609 raise TypeError( 

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

611 ) 

612 kwargs = _prep_component_kwargs( 

613 get_callable_info(component_callable), 

614 _resolve_t_attrs(attrs, template.interpolations), 

615 children=component_template, 

616 provided_attrs=provided_attrs, 

617 raise_on_requires_positional=True, 

618 raise_on_missing=True, 

619 ) 

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

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

622 return res1 

623 elif callable(res1): 

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

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

626 return res2 

627 else: 

628 raise TypeError( 

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

630 ) 

631 else: 

632 raise TypeError( 

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

634 ) 

635 

636 

637class ITemplateProcessor(t.Protocol): 

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

639 

640 

641@dataclass(frozen=True) 

642class TemplateProcessor(ITemplateProcessor): 

643 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy) 

644 

645 component_processor_api: IComponentProcessor = field( 

646 default_factory=ComponentProcessor 

647 ) 

648 

649 escape_html_text: Callable = default_escape_html_text 

650 

651 escape_html_comment: Callable = default_escape_html_comment 

652 

653 escape_html_script: Callable = default_escape_html_script 

654 

655 escape_html_style: Callable = default_escape_html_style 

656 

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

658 

659 uppercase_doctype: bool = False # DOCTYPE vs doctype 

660 

661 def process( 

662 self, 

663 root_template: Template, 

664 assume_ctx: ProcessContext, 

665 ) -> str: 

666 """ 

667 Process a TDOM compatible template into a string. 

668 """ 

669 return self._process_template(root_template, assume_ctx) 

670 

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

672 root = self.parser_api.to_tnode(template) 

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

674 

675 def _process_tnode( 

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

677 ) -> str: 

678 """ 

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

680 """ 

681 match tnode: 

682 case TDocumentType(text): 

683 return self._process_document_type(last_ctx, text) 

684 case TComment(ref): 

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

686 case TFragment(children): 

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

688 case TComponent(start_i_index, end_i_index, children_ref, attrs): 

689 return self._process_component( 

690 template, 

691 last_ctx, 

692 attrs, 

693 start_i_index, 

694 end_i_index, 

695 children_ref, 

696 ) 

697 case TElement(tag, attrs, children): 

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

699 case TText(ref): 

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

701 case _: 

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

703 

704 def _process_document_type( 

705 self, 

706 last_ctx: ProcessContext, 

707 text: str, 

708 ) -> str: 

709 if last_ctx.ns != "html": 

710 # Nit 

711 raise ValueError( 

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

713 ) 

714 if self.uppercase_doctype: 

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

716 else: 

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

718 

719 def _process_fragment( 

720 self, 

721 template: Template, 

722 last_ctx: ProcessContext, 

723 children: Iterable[TNode], 

724 ) -> str: 

725 return "".join( 

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

727 ) 

728 

729 def _process_texts( 

730 self, 

731 template: Template, 

732 last_ctx: ProcessContext, 

733 ref: TemplateRef, 

734 ) -> str: 

735 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: 

736 # Must be handled all at once. 

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

738 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: 

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

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

741 else: 

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

743 

744 def _process_comment( 

745 self, 

746 template: Template, 

747 last_ctx: ProcessContext, 

748 content_ref: TemplateRef, 

749 ) -> str: 

750 """ 

751 Process a comment into a string. 

752 """ 

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

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

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

756 

757 def _process_element( 

758 self, 

759 template: Template, 

760 last_ctx: ProcessContext, 

761 tag: str, 

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

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

764 ) -> str: 

765 out: list[str] = [] 

766 if tag == "svg": 

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

768 elif tag == "math": 

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

770 else: 

771 our_ctx = last_ctx.copy(parent_tag=tag) 

772 if our_ctx.ns == "svg": 

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

774 else: 

775 starttag = endtag = tag 

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

777 if attrs: 

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

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

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

781 if self.slash_void and tag in VOID_ELEMENTS: 

782 out.append(" />") 

783 else: 

784 out.append(">") 

785 if tag not in VOID_ELEMENTS: 

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

787 if tag == "foreignobject": 

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

789 else: 

790 child_ctx = our_ctx 

791 out.extend( 

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

793 ) 

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

795 return "".join(out) 

796 

797 def _process_attrs( 

798 self, 

799 template: Template, 

800 last_ctx: ProcessContext, 

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

802 ) -> str: 

803 """ 

804 Process an element's attributes into a string. 

805 """ 

806 resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) 

807 if last_ctx.ns == "svg": 

808 attrs_str = serialize_html_attrs( 

809 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) 

810 ) 

811 else: 

812 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) 

813 if attrs_str: 

814 return attrs_str 

815 return "" 

816 

817 def _process_component( 

818 self, 

819 template: Template, 

820 last_ctx: ProcessContext, 

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

822 start_i_index: int, 

823 end_i_index: int | None, 

824 children_ref: TemplateRef, 

825 ) -> str: 

826 """ 

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

828 """ 

829 children_template = children_ref.resolve(template.interpolations) 

830 if ( 

831 start_i_index != end_i_index 

832 and end_i_index is not None 

833 and template.interpolations[start_i_index].value 

834 != template.interpolations[end_i_index].value 

835 ): 

836 raise TypeError( 

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

838 ) 

839 component_callable = template.interpolations[start_i_index].value 

840 result_t = self.component_processor_api.process( 

841 template, last_ctx, component_callable, attrs, children_template 

842 ) 

843 if isinstance(result_t, ScopedTemplate): 

844 with result_t.scope.activate(): 

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

846 return self._process_template(result_t, last_ctx) 

847 

848 def _process_raw_texts( 

849 self, 

850 template: Template, 

851 last_ctx: ProcessContext, 

852 content_ref: TemplateRef, 

853 ) -> str: 

854 """ 

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

856 """ 

857 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS 

858 content = resolve_text_without_recursion( 

859 template, last_ctx.parent_tag, content_ref 

860 ) 

861 if last_ctx.parent_tag == "script": 

862 return self.escape_html_script( 

863 content, 

864 allow_markup=True, 

865 ) 

866 elif last_ctx.parent_tag == "style": 

867 return self.escape_html_style( 

868 content, 

869 allow_markup=True, 

870 ) 

871 else: 

872 raise NotImplementedError( 

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

874 ) 

875 

876 def _process_escapable_raw_texts( 

877 self, 

878 template: Template, 

879 last_ctx: ProcessContext, 

880 content_ref: TemplateRef, 

881 ) -> str: 

882 """ 

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

884 """ 

885 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS 

886 content = resolve_text_without_recursion( 

887 template, last_ctx.parent_tag, content_ref 

888 ) 

889 return self.escape_html_text(content) 

890 

891 def _process_normal_texts( 

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

893 ): 

894 """ 

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

896 """ 

897 return "".join( 

898 ( 

899 self.escape_html_text(part) 

900 if isinstance(part, str) 

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

902 ) 

903 for part in content_ref 

904 ) 

905 

906 def _process_normal_text( 

907 self, 

908 template: Template, 

909 last_ctx: ProcessContext, 

910 values_index: int, 

911 ) -> str: 

912 """ 

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

914 

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

916 """ 

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

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

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

920 

921 def _process_normal_text_from_value( 

922 self, 

923 template: Template, 

924 last_ctx: ProcessContext, 

925 value: NormalTextInterpolationValue, 

926 ) -> str: 

927 """ 

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

929 

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

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

932 """ 

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

934 return "" 

935 elif isinstance(value, str): 

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

937 # implementing HasHTMLDunder. 

938 return self.escape_html_text(value) 

939 elif isinstance(value, Template): 

940 return self._process_template(value, last_ctx) 

941 elif isinstance(value, Iterable): 

942 return "".join( 

943 self._process_normal_text_from_value(template, last_ctx, v) 

944 for v in value 

945 ) 

946 elif isinstance(value, HasHTMLDunder): 

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

948 # here for completeness. 

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

950 # custom object with __html__ might not. 

951 return Markup(value.__html__()) 

952 else: 

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

954 # coerced to a str() and emitted. 

955 return self.escape_html_text(value) 

956 

957 

958def resolve_text_without_recursion( 

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

960) -> str: 

961 """ 

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

963 

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

965 

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

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

968 """ 

969 if content_ref.is_singleton: 

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

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

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

973 return "" 

974 elif isinstance(value, str): 

975 return value 

976 elif isinstance(value, HasHTMLDunder): 

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

978 # the interpolation in this special case. 

979 return Markup(value.__html__()) 

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

981 raise ValueError( 

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

983 ) 

984 else: 

985 return str(value) 

986 else: 

987 text = [] 

988 for part in content_ref: 

989 if isinstance(part, str): 

990 if part: 

991 text.append(part) 

992 continue 

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

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

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

996 continue 

997 elif ( 

998 type(value) is str 

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

1000 if value: 

1001 text.append(value) 

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

1003 raise ValueError( 

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

1005 ) 

1006 elif isinstance(value, HasHTMLDunder): 

1007 raise ValueError( 

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

1009 ) 

1010 else: 

1011 value_str = str(value) 

1012 if value_str: 

1013 text.append(value_str) 

1014 return "".join(text) 

1015 

1016 

1017def _make_default_template_processor( 

1018 parser_api: ITemplateParserProxy | None = None, 

1019) -> ITemplateProcessor: 

1020 """ 

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

1022 """ 

1023 return TemplateProcessor( 

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

1025 slash_void=True, 

1026 uppercase_doctype=True, 

1027 ) 

1028 

1029 

1030_default_template_processor_api: ITemplateProcessor = _make_default_template_processor() 

1031 

1032 

1033# -------------------------------------------------------------------------- 

1034# Public API 

1035# -------------------------------------------------------------------------- 

1036 

1037 

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

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

1040 if assume_ctx is None: 

1041 assume_ctx = ProcessContext() 

1042 return _default_template_processor_api.process(template, assume_ctx) 

1043 

1044 

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

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

1047 

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

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

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

1051 

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

1053 is detected automatically. 

1054 """ 

1055 if assume_ctx is None: 

1056 assume_ctx = ProcessContext(ns="svg") 

1057 return html(template, assume_ctx=assume_ctx)