Coverage for tdom/processor.py: 98%

422 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-23 04:35 +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 .scope import ScopedTemplate 

50from .template_utils import TemplateRef 

51from .utils import CachableTemplate, LastUpdatedOrderedDict 

52 

53type Attribute = tuple[str, object] 

54type AttributesDict = dict[str, object] 

55 

56 

57# -------------------------------------------------------------------------- 

58# Custom formatting for the processor 

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

60 

61 

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

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

64 assert format_spec == "safe" 

65 return Markup(value) 

66 

67 

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

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

70 assert format_spec == "unsafe" 

71 return str(value) 

72 

73 

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

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

76 assert format_spec == "callback" 

77 return value() 

78 

79 

80CUSTOM_FORMATTERS = ( 

81 ("safe", _format_safe), 

82 ("unsafe", _format_unsafe), 

83 ("callback", _format_callback), 

84) 

85 

86 

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

88 return base_format_interpolation( 

89 interpolation, 

90 formatters=CUSTOM_FORMATTERS, 

91 ) 

92 

93 

94# -------------------------------------------------------------------------- 

95# Placeholder Substitution 

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

97 

98 

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

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

101 if value is None: 

102 return 

103 elif isinstance(value, dict): 

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

105 if sub_v is True: 

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

107 elif sub_v is False: 

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

109 elif sub_v is None: 

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

111 else: 

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

113 else: 

114 raise TypeError( 

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

116 ) 

117 

118 

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

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

121 if value is None: 

122 return 

123 elif isinstance(value, dict): 

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

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

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

127 else: 

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

129 else: 

130 raise TypeError( 

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

132 ) 

133 

134 

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

136 """ 

137 Substitute a spread attribute based on the interpolated value. 

138 

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

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

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

142 """ 

143 if value is None: 

144 return 

145 elif isinstance(value, dict): 

146 yield from value.items() 

147 else: 

148 raise TypeError( 

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

150 ) 

151 

152 

153ATTR_EXPANDERS = { 

154 "data": _expand_data_attr, 

155 "aria": _expand_aria_attr, 

156} 

157 

158 

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

160 """ 

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

162 """ 

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

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

165 for prop in props: 

166 if prop: 

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

168 if len(prop_parts) != 2: 

169 raise ValueError( 

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

171 ) 

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

173 return styles 

174 

175 

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

177 """ 

178 Initialize the style accumulator. 

179 """ 

180 match old_value: 

181 case str(): 

182 styles = { 

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

184 } 

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

186 styles = {} 

187 case _: 

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

189 return StyleAccumulator(styles=styles) 

190 

191 

192@dataclass 

193class StyleAccumulator: 

194 styles: dict[str, str | None] 

195 

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

197 """ 

198 Merge in an interpolated style value. 

199 """ 

200 match value: 

201 case str(): 

202 self.styles.update( 

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

204 ) 

205 case dict(): 

206 self.styles.update( 

207 { 

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

209 for pn, pv in value.items() 

210 } 

211 ) 

212 case None: 

213 pass 

214 case _: 

215 raise TypeError( 

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

217 ) 

218 

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

220 """ 

221 Serialize the special style value back into a string. 

222 

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

224 """ 

225 style_value = "; ".join( 

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

227 ) 

228 return style_value if style_value else None 

229 

230 

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

232 """ 

233 Initialize the class accumulator. 

234 """ 

235 match old_value: 

236 case str(): 

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

238 case True: 

239 toggled_classes = {} 

240 case _: 

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

242 return ClassAccumulator(toggled_classes=toggled_classes) 

243 

244 

245@dataclass 

246class ClassAccumulator: 

247 toggled_classes: dict[str, bool] 

248 

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

250 """ 

251 Merge in an interpolated class value. 

252 """ 

253 if isinstance(value, dict): 

254 self.toggled_classes.update( 

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

256 ) 

257 else: 

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

259 items = value[:] 

260 else: 

261 items = (value,) 

262 for item in items: 

263 match item: 

264 case str(): 

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

266 case None: 

267 pass 

268 case _: 

269 if item == value: 

270 raise TypeError( 

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

272 ) 

273 else: 

274 raise TypeError( 

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

276 ) 

277 

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

279 """ 

280 Serialize the special class value back into a string. 

281 

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

283 """ 

284 class_value = " ".join( 

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

286 ) 

287 return class_value if class_value else None 

288 

289 

290ATTR_ACCUMULATOR_MAKERS = { 

291 "class": make_class_accumulator, 

292 "style": make_style_accumulator, 

293} 

294 

295 

296type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator 

297 

298 

299def _resolve_t_attrs( 

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

301) -> AttributesDict: 

302 """ 

303 Replace placeholder values in attributes with their interpolated values. 

304 

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

306 in a later step. 

307 

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

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

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

311 """ 

312 new_attrs: AttributesDict = LastUpdatedOrderedDict() 

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

314 for attr in attrs: 

315 match attr: 

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

317 attr_value = True if value is None else value 

318 if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs: 

319 if name not in attr_accs: 

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

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

322 else: 

323 new_attrs[name] = attr_value 

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

325 interpolation = interpolations[i_index] 

326 attr_value = format_interpolation(interpolation) 

327 if name in ATTR_ACCUMULATOR_MAKERS: 

328 if name not in attr_accs: 

329 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

330 new_attrs.get(name, True) 

331 ) 

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

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

334 for sub_k, sub_v in expander(attr_value): 

335 new_attrs[sub_k] = sub_v 

336 else: 

337 new_attrs[name] = attr_value 

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

339 attr_t = ref.resolve(interpolations) 

340 attr_value = format_template(attr_t) 

341 if name in ATTR_ACCUMULATOR_MAKERS: 

342 if name not in attr_accs: 

343 attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( 

344 new_attrs.get(name, True) 

345 ) 

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

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

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

349 else: 

350 new_attrs[name] = attr_value 

351 case TSpreadAttribute(i_index=i_index): 

352 interpolation = interpolations[i_index] 

353 spread_value = format_interpolation(interpolation) 

354 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

355 if sub_k in ATTR_ACCUMULATOR_MAKERS: 

356 if sub_k not in attr_accs: 

357 attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k]( 

358 new_attrs.get(sub_k, True) 

359 ) 

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

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

362 for exp_k, exp_v in expander(sub_v): 

363 new_attrs[exp_k] = exp_v 

364 else: 

365 new_attrs[sub_k] = sub_v 

366 case _: 

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

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

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

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

371 return new_attrs 

372 

373 

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

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

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

377 match value: 

378 case True: 

379 yield key, None 

380 case False | None: 

381 pass 

382 case _: 

383 yield key, str(value) 

384 

385 

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

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

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

389 

390 

391def _prep_component_kwargs( 

392 callable_info: CallableInfo, 

393 attrs: AttributesDict, 

394 children: Template, 

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

396 raise_on_requires_positional=True, 

397 raise_on_missing=True, 

398) -> AttributesDict: 

399 """ 

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

401 

402 `provided_attrs`: 

403 These can be used by extensions that want to provide 

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

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

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

407 

408 `raise_on_requires_positional`: 

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

410 positional arguments which we cannot fulfill normally. 

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

412 the arguments after this call. 

413 

414 `raise_on_missing`: 

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

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

417 raise an exception whose cause might not be clear. 

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

419 the arguments after this call. 

420 """ 

421 

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

423 if raise_on_requires_positional and callable_info.requires_positional: 

424 raise TypeError( 

425 "Component callables cannot have required positional arguments." 

426 ) 

427 

428 kwargs: AttributesDict = {} 

429 

430 # Add all supported attributes 

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

432 snake_name = _kebab_to_snake(attr_name) 

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

434 kwargs[snake_name] = attr_value 

435 else: 

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

437 

438 if "children" in kwargs: 

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

440 

441 if "children" in callable_info.named_params: 

442 kwargs["children"] = children 

443 

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

445 for pattr_name, pattr_value in provided_attrs: 

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

447 kwargs[pattr_name] = pattr_value 

448 

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

450 if raise_on_missing: 

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

452 if missing: 

453 raise TypeError( 

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

455 ) 

456 

457 return kwargs 

458 

459 

460def serialize_html_attrs( 

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

462) -> str: 

463 return "".join( 

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

465 ) 

466 

467 

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

469 """ 

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

471 """ 

472 for k, v in html_attrs: 

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

474 

475 

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

477class ProcessContext: 

478 parent_tag: str = DEFAULT_NORMAL_TEXT_ELEMENT 

479 ns: str = "html" 

480 

481 def copy( 

482 self, 

483 ns: str | None = None, 

484 parent_tag: str | None = None, 

485 ) -> ProcessContext: 

486 return ProcessContext( 

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

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

489 ) 

490 

491 

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

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

494type ComponentCallable = FunctionComponent | FactoryComponent 

495type ComponentObject = Callable[[], Template] 

496 

497 

498type NormalTextInterpolationValue = ( 

499 None 

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

501 | str 

502 | HasHTMLDunder 

503 | Template 

504 | Iterable[NormalTextInterpolationValue] 

505 | object 

506) 

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

508type RawTextExactInterpolationValue = ( 

509 None 

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

511 | str 

512 | HasHTMLDunder 

513 | object 

514) 

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

516type RawTextInexactInterpolationValue = ( 

517 None 

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

519 | str 

520 | object 

521) 

522 

523 

524class ITemplateParserProxy(t.Protocol): 

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

526 

527 

528@dataclass(frozen=True) 

529class TemplateParserProxy(ITemplateParserProxy): 

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

531 return TemplateParser.parse(template) 

532 

533 

534@dataclass(frozen=True) 

535class CachedTemplateParserProxy(TemplateParserProxy): 

536 @lru_cache(512) # noqa: B019 

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

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

539 

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

541 return self._to_tnode(CachableTemplate(template)) 

542 

543 

544class IComponentProcessor(t.Protocol): 

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

546 

547 def process( 

548 self, 

549 template: Template, 

550 last_ctx: ProcessContext, 

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

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

553 component_template: Template, 

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

555 ) -> Template | ScopedTemplate: 

556 """ 

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

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

559 """ 

560 ... 

561 

562 

563class ComponentProcessor(IComponentProcessor): 

564 """ 

565 Default component processor. 

566 """ 

567 

568 def process( 

569 self, 

570 template: Template, 

571 last_ctx: ProcessContext, 

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

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

574 component_template: Template, 

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

576 ) -> Template | ScopedTemplate: 

577 """ 

578 Process available component details into a Template. 

579 

580 Two general "styles" are supported: 

581 

582 1. FunctionComponent 

583 

584 Calling `component_callable` with the prepared kwargs should 

585 return a `Template`. 

586 

587 The primary purpose of this style is to support 

588 using a normal function as a component. 

589 

590 2. FactoryComponent 

591 

592 Calling `component_callable` with the prepared kwargs should 

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

594 return a `Template`. 

595 

596 The primary purpose of this style is to support 

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

598 component. 

599 

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

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

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

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

604 won't construct one directly. 

605 """ 

606 if not callable(component_callable): 

607 raise TypeError( 

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

609 ) 

610 kwargs = _prep_component_kwargs( 

611 get_callable_info(component_callable), 

612 _resolve_t_attrs(attrs, template.interpolations), 

613 children=component_template, 

614 provided_attrs=provided_attrs, 

615 raise_on_requires_positional=True, 

616 raise_on_missing=True, 

617 ) 

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

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

620 return res1 

621 elif callable(res1): 

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

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

624 return res2 

625 else: 

626 raise TypeError( 

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

628 ) 

629 else: 

630 raise TypeError( 

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

632 ) 

633 

634 

635class ITemplateProcessor(t.Protocol): 

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

637 

638 

639@dataclass(frozen=True) 

640class TemplateProcessor(ITemplateProcessor): 

641 parser_api: ITemplateParserProxy = field(default_factory=CachedTemplateParserProxy) 

642 

643 component_processor_api: IComponentProcessor = field( 

644 default_factory=ComponentProcessor 

645 ) 

646 

647 escape_html_text: Callable = default_escape_html_text 

648 

649 escape_html_comment: Callable = default_escape_html_comment 

650 

651 escape_html_script: Callable = default_escape_html_script 

652 

653 escape_html_style: Callable = default_escape_html_style 

654 

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

656 

657 uppercase_doctype: bool = False # DOCTYPE vs doctype 

658 

659 def process( 

660 self, 

661 root_template: Template, 

662 assume_ctx: ProcessContext, 

663 ) -> str: 

664 """ 

665 Process a TDOM compatible template into a string. 

666 """ 

667 return self._process_template(root_template, assume_ctx) 

668 

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

670 root = self.parser_api.to_tnode(template) 

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

672 

673 def _process_tnode( 

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

675 ) -> str: 

676 """ 

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

678 """ 

679 match tnode: 

680 case TDocumentType(text): 

681 return self._process_document_type(last_ctx, text) 

682 case TComment(ref): 

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

684 case TFragment(children): 

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

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

687 return self._process_component( 

688 template, 

689 last_ctx, 

690 attrs, 

691 start_i_index, 

692 end_i_index, 

693 children_ref, 

694 ) 

695 case TElement(tag, attrs, children): 

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

697 case TText(ref): 

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

699 case _: 

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

701 

702 def _process_document_type( 

703 self, 

704 last_ctx: ProcessContext, 

705 text: str, 

706 ) -> str: 

707 if last_ctx.ns != "html": 

708 # Nit 

709 raise ValueError( 

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

711 ) 

712 if self.uppercase_doctype: 

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

714 else: 

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

716 

717 def _process_fragment( 

718 self, 

719 template: Template, 

720 last_ctx: ProcessContext, 

721 children: Iterable[TNode], 

722 ) -> str: 

723 return "".join( 

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

725 ) 

726 

727 def _process_texts( 

728 self, 

729 template: Template, 

730 last_ctx: ProcessContext, 

731 ref: TemplateRef, 

732 ) -> str: 

733 if last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: 

734 # Must be handled all at once. 

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

736 elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: 

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

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

739 else: 

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

741 

742 def _process_comment( 

743 self, 

744 template: Template, 

745 last_ctx: ProcessContext, 

746 content_ref: TemplateRef, 

747 ) -> str: 

748 """ 

749 Process a comment into a string. 

750 """ 

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

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

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

754 

755 def _process_element( 

756 self, 

757 template: Template, 

758 last_ctx: ProcessContext, 

759 tag: str, 

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

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

762 ) -> str: 

763 out: list[str] = [] 

764 if tag == "svg": 

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

766 elif tag == "math": 

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

768 else: 

769 our_ctx = last_ctx.copy(parent_tag=tag) 

770 if our_ctx.ns == "svg": 

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

772 else: 

773 starttag = endtag = tag 

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

775 if attrs: 

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

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

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

779 if self.slash_void and tag in VOID_ELEMENTS: 

780 out.append(" />") 

781 else: 

782 out.append(">") 

783 if tag not in VOID_ELEMENTS: 

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

785 if tag == "foreignobject": 

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

787 else: 

788 child_ctx = our_ctx 

789 out.extend( 

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

791 ) 

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

793 return "".join(out) 

794 

795 def _process_attrs( 

796 self, 

797 template: Template, 

798 last_ctx: ProcessContext, 

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

800 ) -> str: 

801 """ 

802 Process an element's attributes into a string. 

803 """ 

804 resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) 

805 if last_ctx.ns == "svg": 

806 attrs_str = serialize_html_attrs( 

807 _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) 

808 ) 

809 else: 

810 attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) 

811 if attrs_str: 

812 return attrs_str 

813 return "" 

814 

815 def _process_component( 

816 self, 

817 template: Template, 

818 last_ctx: ProcessContext, 

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

820 start_i_index: int, 

821 end_i_index: int | None, 

822 children_ref: TemplateRef, 

823 ) -> str: 

824 """ 

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

826 """ 

827 children_template = children_ref.resolve(template.interpolations) 

828 if ( 

829 start_i_index != end_i_index 

830 and end_i_index is not None 

831 and template.interpolations[start_i_index].value 

832 != template.interpolations[end_i_index].value 

833 ): 

834 raise TypeError( 

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

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 if isinstance(result_t, ScopedTemplate): 

842 with result_t.scope.activate(): 

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

844 return self._process_template(result_t, last_ctx) 

845 

846 def _process_raw_texts( 

847 self, 

848 template: Template, 

849 last_ctx: ProcessContext, 

850 content_ref: TemplateRef, 

851 ) -> str: 

852 """ 

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

854 """ 

855 assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS 

856 content = resolve_text_without_recursion( 

857 template, last_ctx.parent_tag, content_ref 

858 ) 

859 if last_ctx.parent_tag == "script": 

860 return self.escape_html_script( 

861 content, 

862 allow_markup=True, 

863 ) 

864 elif last_ctx.parent_tag == "style": 

865 return self.escape_html_style( 

866 content, 

867 allow_markup=True, 

868 ) 

869 else: 

870 raise NotImplementedError( 

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

872 ) 

873 

874 def _process_escapable_raw_texts( 

875 self, 

876 template: Template, 

877 last_ctx: ProcessContext, 

878 content_ref: TemplateRef, 

879 ) -> str: 

880 """ 

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

882 """ 

883 assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS 

884 content = resolve_text_without_recursion( 

885 template, last_ctx.parent_tag, content_ref 

886 ) 

887 return self.escape_html_text(content) 

888 

889 def _process_normal_texts( 

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

891 ): 

892 """ 

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

894 """ 

895 return "".join( 

896 ( 

897 self.escape_html_text(part) 

898 if isinstance(part, str) 

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

900 ) 

901 for part in content_ref 

902 ) 

903 

904 def _process_normal_text( 

905 self, 

906 template: Template, 

907 last_ctx: ProcessContext, 

908 values_index: int, 

909 ) -> str: 

910 """ 

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

912 

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

914 """ 

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

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

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

918 

919 def _process_normal_text_from_value( 

920 self, 

921 template: Template, 

922 last_ctx: ProcessContext, 

923 value: NormalTextInterpolationValue, 

924 ) -> str: 

925 """ 

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

927 

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

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

930 """ 

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

932 return "" 

933 elif isinstance(value, str): 

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

935 # implementing HasHTMLDunder. 

936 return self.escape_html_text(value) 

937 elif isinstance(value, Template): 

938 return self._process_template(value, last_ctx) 

939 elif isinstance(value, Iterable): 

940 return "".join( 

941 self._process_normal_text_from_value(template, last_ctx, v) 

942 for v in value 

943 ) 

944 elif isinstance(value, HasHTMLDunder): 

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

946 # here for completeness. 

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

948 # custom object with __html__ might not. 

949 return Markup(value.__html__()) 

950 else: 

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

952 # coerced to a str() and emitted. 

953 return self.escape_html_text(value) 

954 

955 

956def resolve_text_without_recursion( 

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

958) -> str: 

959 """ 

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

961 

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

963 

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

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

966 """ 

967 if content_ref.is_singleton: 

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

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

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

971 return "" 

972 elif isinstance(value, str): 

973 return value 

974 elif isinstance(value, HasHTMLDunder): 

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

976 # the interpolation in this special case. 

977 return Markup(value.__html__()) 

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

979 raise ValueError( 

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

981 ) 

982 else: 

983 return str(value) 

984 else: 

985 text = [] 

986 for part in content_ref: 

987 if isinstance(part, str): 

988 if part: 

989 text.append(part) 

990 continue 

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

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

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

994 continue 

995 elif ( 

996 type(value) is str 

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

998 if value: 

999 text.append(value) 

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

1001 raise ValueError( 

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

1003 ) 

1004 elif isinstance(value, HasHTMLDunder): 

1005 raise ValueError( 

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

1007 ) 

1008 else: 

1009 value_str = str(value) 

1010 if value_str: 

1011 text.append(value_str) 

1012 return "".join(text) 

1013 

1014 

1015def _make_default_template_processor( 

1016 parser_api: ITemplateParserProxy | None = None, 

1017) -> ITemplateProcessor: 

1018 """ 

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

1020 """ 

1021 return TemplateProcessor( 

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

1023 slash_void=True, 

1024 uppercase_doctype=True, 

1025 ) 

1026 

1027 

1028_default_template_processor_api: ITemplateProcessor = _make_default_template_processor() 

1029 

1030 

1031# -------------------------------------------------------------------------- 

1032# Public API 

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

1034 

1035 

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

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

1038 if assume_ctx is None: 

1039 assume_ctx = ProcessContext() 

1040 return _default_template_processor_api.process(template, assume_ctx) 

1041 

1042 

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

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

1045 

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

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

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

1049 

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

1051 is detected automatically. 

1052 """ 

1053 if assume_ctx is None: 

1054 assume_ctx = ProcessContext(ns="svg") 

1055 return html(template, assume_ctx=assume_ctx)