Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-htmx/plain/htmx/templates.py: 25%

88 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1import jinja2 

2from jinja2 import meta, nodes 

3from jinja2.ext import Extension 

4 

5from plain.runtime import settings 

6from plain.templates import register_template_extension 

7from plain.templates.jinja.extensions import InclusionTagExtension 

8 

9 

10@register_template_extension 

11class HTMXJSExtension(InclusionTagExtension): 

12 tags = {"htmx_js"} 

13 template_name = "htmx/js.html" 

14 

15 def get_context(self, context, *args, **kwargs): 

16 return { 

17 "csrf_token": context["csrf_token"], 

18 "DEBUG": settings.DEBUG, 

19 "extensions": kwargs.get("extensions", []), 

20 } 

21 

22 

23@register_template_extension 

24class HTMXFragmentExtension(Extension): 

25 tags = {"htmxfragment"} 

26 

27 def __init__(self, environment): 

28 super().__init__(environment) 

29 environment.extend(htmx_fragment_nodes={}) 

30 

31 def parse(self, parser): 

32 lineno = next(parser.stream).lineno 

33 

34 fragment_name = parser.parse_expression() 

35 

36 kwargs = [] 

37 

38 while parser.stream.current.type != "block_end": 

39 if parser.stream.current.type == "name": 

40 key = parser.stream.current.value 

41 parser.stream.skip() 

42 parser.stream.expect("assign") 

43 value = parser.parse_expression() 

44 kwargs.append(nodes.Keyword(key, value)) 

45 

46 body = parser.parse_statements(["name:endhtmxfragment"], drop_needle=True) 

47 

48 call = self.call_method( 

49 "_render_htmx_fragment", 

50 args=[fragment_name, jinja2.nodes.ContextReference()], 

51 kwargs=kwargs, 

52 ) 

53 

54 node = jinja2.nodes.CallBlock(call, [], [], body).set_lineno(lineno) 

55 

56 # Store a reference to the node for later 

57 self.environment.htmx_fragment_nodes.setdefault(parser.name, {})[ 

58 fragment_name.value 

59 ] = node 

60 

61 return node 

62 

63 def _render_htmx_fragment(self, fragment_name, context, caller, **kwargs): 

64 def attrs_to_str(attrs): 

65 parts = [] 

66 for k, v in attrs.items(): 

67 if v == "": 

68 parts.append(k) 

69 else: 

70 parts.append(f'{k}="{v}"') 

71 return " ".join(parts) 

72 

73 render_lazy = kwargs.get("lazy", False) 

74 as_element = kwargs.get("as", "div") 

75 attrs = {} 

76 for k, v in kwargs.items(): 

77 if k.startswith("hx_"): 

78 attrs[k.replace("_", "-")] = v 

79 else: 

80 attrs[k] = v 

81 

82 if render_lazy: 

83 attrs.setdefault("hx-swap", "outerHTML") 

84 attrs.setdefault("hx-target", "this") 

85 attrs.setdefault("hx-indicator", "this") 

86 attrs_str = attrs_to_str(attrs) 

87 return f'<{as_element} plain-hx-fragment="{fragment_name}" hx-get hx-trigger="plainhtmx:load from:body" {attrs_str}></{as_element}>' 

88 else: 

89 # Swap innerHTML so we can re-run hx calls inside the fragment automatically 

90 # (render_template_fragment won't render this part of the node again, just the inner nodes) 

91 attrs.setdefault("hx-swap", "innerHTML") 

92 attrs.setdefault("hx-target", "this") 

93 attrs.setdefault("hx-indicator", "this") 

94 # Add an id that you can use to target the fragment from outside the fragment 

95 attrs.setdefault("id", f"plain-hx-fragment-{fragment_name}") 

96 attrs_str = attrs_to_str(attrs) 

97 return f'<{as_element} plain-hx-fragment="{fragment_name}" {attrs_str}>{caller()}</{as_element}>' 

98 

99 

100def render_template_fragment(*, template, fragment_name, context): 

101 template = find_template_fragment(template, fragment_name) 

102 return template.render(context) 

103 

104 

105def find_template_fragment(template: jinja2.Template, fragment_name: str): 

106 # Look in this template for the fragment 

107 callblock_node = template.environment.htmx_fragment_nodes.get( 

108 template.name, {} 

109 ).get(fragment_name) 

110 

111 if not callblock_node: 

112 # Look in other templates for this fragment 

113 matching_callblock_nodes = [] 

114 for fragments in template.environment.htmx_fragment_nodes.values(): 

115 if fragment_name in fragments: 

116 matching_callblock_nodes.append(fragments[fragment_name]) 

117 

118 if len(matching_callblock_nodes) == 0: 

119 # If we still haven't found anything, it's possible that we're 

120 # in a different/new worker/process and haven't parsed the related templates yet 

121 ast = template.environment.parse( 

122 template.environment.loader.get_source( 

123 template.environment, template.name 

124 )[0] 

125 ) 

126 for ref in meta.find_referenced_templates(ast): 

127 if ref not in template.environment.htmx_fragment_nodes: 

128 # Trigger them to parse 

129 template.environment.get_template(ref) 

130 

131 # Now look again 

132 for fragments in template.environment.htmx_fragment_nodes.values(): 

133 if fragment_name in fragments: 

134 matching_callblock_nodes.append(fragments[fragment_name]) 

135 

136 if len(matching_callblock_nodes) == 1: 

137 callblock_node = matching_callblock_nodes[0] 

138 elif len(matching_callblock_nodes) > 1: 

139 raise jinja2.TemplateNotFound( 

140 f"Fragment {fragment_name} found in multiple templates. Use a more specific name." 

141 ) 

142 else: 

143 raise jinja2.TemplateNotFound( 

144 f"Fragment {fragment_name} not found in any templates" 

145 ) 

146 

147 if not callblock_node: 

148 raise jinja2.TemplateNotFound( 

149 f"Fragment {fragment_name} not found in template {template.name}" 

150 ) 

151 

152 # Create a new template from the node 

153 template_node = jinja2.nodes.Template(callblock_node.body) 

154 return template.environment.from_string(template_node)