Coverage for src/minihtml/_template.py: 100%

64 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-06 18:07 +0200

1import io 

2from collections.abc import Iterable 

3from functools import wraps 

4from typing import Callable, Concatenate, ParamSpec, TextIO, TypeAlias, overload 

5 

6from ._component import Component, ComponentWrapper 

7from ._core import HasNodes, Node, iter_nodes, register_with_context 

8from ._template_context import get_template_context, template_context 

9 

10P = ParamSpec("P") 

11 

12TemplateImpl: TypeAlias = Callable[P, Node | HasNodes] 

13TemplateImplLayout: TypeAlias = Callable[Concatenate[Component, P], None] 

14 

15 

16class Template: 

17 """ 

18 The result of calling a function decorated with :deco:`template`. 

19 """ 

20 

21 def __init__(self, callback: Callable[[], list[Node]]): 

22 self._callback = callback 

23 

24 def render(self, *, doctype: bool = True) -> str: 

25 """ 

26 Render the template and return a string. 

27 

28 Args: 

29 doctype: Whether or not to prepend the doctype declaration 

30 ``<!doctype html>`` to the output. 

31 """ 

32 nodes = self._callback() 

33 buf = io.StringIO() 

34 if doctype: 

35 buf.write("<!doctype html>\n") 

36 Node.render_list(buf, nodes) 

37 buf.write("\n") 

38 return buf.getvalue() 

39 

40 

41@overload 

42def template() -> Callable[[TemplateImpl[P]], Callable[P, Template]]: ... 

43 

44 

45@overload 

46def template( 

47 layout: ComponentWrapper[...], 

48) -> Callable[[TemplateImplLayout[P]], Callable[P, Template]]: ... 

49 

50 

51def template( 

52 layout: ComponentWrapper[...] | None = None, 

53) -> ( 

54 Callable[[TemplateImpl[P]], Callable[P, Template]] 

55 | Callable[[TemplateImplLayout[P]], Callable[P, Template]] 

56): 

57 """ 

58 Decorator to create a template. 

59 

60 Args: 

61 layout: A component to use as the layout for the template. The layout 

62 must have a default slot. 

63 

64 When ``layout`` is used, the decorated function will be executed within the 

65 context of the layout component when the template is rendered. All elements 

66 created in the function body will be added to the default slot of the 

67 component. The function will receive the component as the first positional 

68 argument, and should return `None`. 

69 

70 When ``layout`` is not used, the function should return the content to be 

71 rendered. 

72 

73 The template will collect and deduplicate the style and script nodes of all 

74 components used within the template (including the layout component). These 

75 nodes can be inserted into the document by using the 

76 :func:`component_styles()` and :func:`component_scripts()` placeholders. 

77 """ 

78 if layout is None: 

79 

80 def plain_decorator(fn: TemplateImpl[P]) -> Callable[P, Template]: 

81 @wraps(fn) 

82 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Template: 

83 def callback() -> list[Node]: 

84 with template_context(): 

85 result = fn(*args, **kwargs) 

86 return list(iter_nodes([result])) 

87 

88 return Template(callback) 

89 

90 return wrapper 

91 

92 return plain_decorator 

93 

94 else: 

95 

96 def layout_decorator(fn: TemplateImplLayout[P]) -> Callable[P, Template]: 

97 @wraps(fn) 

98 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Template: 

99 def callback() -> list[Node]: 

100 with template_context(): 

101 with layout() as result: 

102 fn(result, *args, **kwargs) 

103 return list(iter_nodes([result])) 

104 

105 return Template(callback) 

106 

107 return wrapper 

108 

109 return layout_decorator 

110 

111 

112class ResourceWrapper(Node): 

113 def __init__(self, nodes: Iterable[Node]): 

114 self._nodes = nodes 

115 self._inline = False 

116 

117 def write(self, f: TextIO, indent: int = 0) -> None: 

118 nodes = list(self._nodes) 

119 n = len(nodes) 

120 for i, node in enumerate(nodes): 

121 node.write(f, indent) 

122 if i < n - 1: 

123 f.write("\n") 

124 f.write(" " * indent) 

125 

126 

127def component_styles() -> ResourceWrapper: 

128 """ 

129 Placeholder element for component styles. 

130 

131 Can only be used in code called from a function decorated with 

132 :deco:`template`. Inserts the style nodes collected from all components 

133 used in the current template. 

134 """ 

135 wrapper = ResourceWrapper(get_template_context().styles) 

136 register_with_context(wrapper) 

137 return wrapper 

138 

139 

140def component_scripts() -> ResourceWrapper: 

141 """ 

142 Placeholder element for component scripts. 

143 

144 Can only be used in code called from a function decorated with 

145 :deco:`template`. Inserts the script nodes collected from all components 

146 used in the current template. 

147 """ 

148 wrapper = ResourceWrapper(get_template_context().scripts) 

149 register_with_context(wrapper) 

150 return wrapper