python-fragments 0.29__tar.gz → 0.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {python_fragments-0.29 → python_fragments-0.32}/PKG-INFO +1 -1
  2. {python_fragments-0.29 → python_fragments-0.32}/fragments/ast_nodes.py +85 -64
  3. {python_fragments-0.29 → python_fragments-0.32}/fragments/grammar.py +15 -3
  4. python_fragments-0.32/fragments/html/elements.py +47 -0
  5. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/hover.py +1 -0
  6. python_fragments-0.32/fragments/types.py +1 -0
  7. {python_fragments-0.29 → python_fragments-0.32}/pyproject.toml +1 -1
  8. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/PKG-INFO +1 -1
  9. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/SOURCES.txt +1 -0
  10. python_fragments-0.32/tests/test_end_to_end.py +183 -0
  11. {python_fragments-0.29 → python_fragments-0.32}/tests/test_grammar.py +48 -0
  12. {python_fragments-0.29 → python_fragments-0.32}/tests/test_html_elements.py +1 -11
  13. {python_fragments-0.29 → python_fragments-0.32}/tests/test_source_map.py +6 -5
  14. python_fragments-0.29/fragments/html/elements.py +0 -73
  15. python_fragments-0.29/fragments/types.py +0 -9
  16. {python_fragments-0.29 → python_fragments-0.32}/LICENSE +0 -0
  17. {python_fragments-0.29 → python_fragments-0.32}/README.md +0 -0
  18. {python_fragments-0.29 → python_fragments-0.32}/fragments/__init__.py +0 -0
  19. {python_fragments-0.29 → python_fragments-0.32}/fragments/cli.py +0 -0
  20. {python_fragments-0.29 → python_fragments-0.32}/fragments/html/__init__.py +0 -0
  21. {python_fragments-0.29 → python_fragments-0.32}/fragments/loader.py +0 -0
  22. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/__init__.py +0 -0
  23. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/based_proxy.py +0 -0
  24. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/__init__.py +0 -0
  25. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/code_actions.py +0 -0
  26. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/completion.py +0 -0
  27. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/definition.py +0 -0
  28. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/diagnostics.py +0 -0
  29. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/document_highlight.py +0 -0
  30. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/document_symbols.py +0 -0
  31. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/folding_range.py +0 -0
  32. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/inlay_hints.py +0 -0
  33. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/lifecycle.py +0 -0
  34. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/references.py +0 -0
  35. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/rename.py +0 -0
  36. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/semantic_tokens.py +0 -0
  37. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/signature_help.py +0 -0
  38. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/file_state.py +0 -0
  39. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/message_queue.py +0 -0
  40. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/__init__.py +0 -0
  41. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/capability.py +0 -0
  42. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/configuration.py +0 -0
  43. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/diagnostics.py +0 -0
  44. {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/types.py +0 -0
  45. {python_fragments-0.29 → python_fragments-0.32}/fragments/source.py +0 -0
  46. {python_fragments-0.29 → python_fragments-0.32}/fragments/transpiler.py +0 -0
  47. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/dependency_links.txt +0 -0
  48. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/entry_points.txt +0 -0
  49. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/requires.txt +0 -0
  50. {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/top_level.txt +0 -0
  51. {python_fragments-0.29 → python_fragments-0.32}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fragments
3
- Version: 0.29
3
+ Version: 0.32
4
4
  Summary: Modern HTML template rendering in Python
5
5
  Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
6
  License: MIT License
@@ -1,7 +1,10 @@
1
1
  from dataclasses import dataclass, field
2
2
  from typing import Sequence
3
3
 
4
- type ASTHTMLChild = ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation | ASTComponent | ASTControlNode | ASTDoctype
4
+ type ASTHTMLChild = ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation | ASTComponent | ASTControlNode | ASTDoctype | ASTChildrenSlot
5
+
6
+ IMPORT_PREFIX = "from fragments.html.elements import attribute_to_string, comment\n"
7
+ IMPORT_PREFIX_LEN = len(IMPORT_PREFIX)
5
8
 
6
9
 
7
10
  @dataclass(slots=True)
@@ -15,18 +18,17 @@ class ASTModule:
15
18
  transpiled_start: int = field(init=False)
16
19
  transpiled_end: int = field(init=False)
17
20
 
18
- __template__: str = "from fragments.html.elements import el, sequence, comment\n{}"
21
+ __template__: str = "{}\n{}"
19
22
 
20
23
  def transpile(self, transpiled_start: int = 0) -> None:
21
24
  """Build transpiled outputs for the module."""
22
25
  self.transpiled_start = transpiled_start
23
- transpiled_start += len(self.__template__) - 2
26
+ self.transpiled_content = IMPORT_PREFIX
27
+
24
28
  for child in self.children:
25
- child.transpile(transpiled_start)
26
- transpiled_start = child.transpiled_end
29
+ child.transpile(self.transpiled_start + len(self.transpiled_content))
30
+ self.transpiled_content += child.transpiled_content
27
31
 
28
- children: str = "".join(child.transpiled_content for child in self.children)
29
- self.transpiled_content = self.__template__.format(children)
30
32
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
31
33
 
32
34
  def map_offset(self, offset: int) -> int | None:
@@ -83,18 +85,18 @@ class ASTFragment:
83
85
  transpiled_start: int = field(init=False)
84
86
  transpiled_end: int = field(init=False)
85
87
 
86
- __template__: str = """sequence([{}])"""
87
-
88
88
  def transpile(self, transpiled_start: int) -> None:
89
89
  self.transpiled_start = transpiled_start
90
- transpiled_start += 10
91
- for child in self.children:
92
- child.transpile(transpiled_start)
93
- transpiled_start = child.transpiled_end + 1
94
- if len(self.children) > 0:
95
- transpiled_start -= 1
90
+ self.transpiled_content = '""'
96
91
 
97
- self.transpiled_content = self.__template__.format(",".join(child.transpiled_content for child in self.children))
92
+ for child in self.children:
93
+ self.transpiled_content += "+"
94
+ if isinstance(child, ASTInterpolation):
95
+ self.transpiled_content += "str("
96
+ child.transpile(self.transpiled_start + len(self.transpiled_content))
97
+ self.transpiled_content += child.transpiled_content
98
+ if isinstance(child, ASTInterpolation):
99
+ self.transpiled_content += ")"
98
100
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
99
101
 
100
102
  def map_offset(self, offset: int) -> int | None:
@@ -126,26 +128,32 @@ class ASTHTMLElement:
126
128
  transpiled_start: int = field(init=False)
127
129
  transpiled_end: int = field(init=False)
128
130
 
129
- __element_template__: str = """el("{}",[{}],oneline={},attributes={})"""
130
-
131
131
  def transpile(self, transpiled_start: int) -> None:
132
132
  self.transpiled_start = transpiled_start
133
- transpiled_start = transpiled_start + len(self.name) + 7
134
- for child in self.children:
135
- child.transpile(transpiled_start)
136
- transpiled_start = child.transpiled_end + 1
137
- if len(self.children) > 0:
138
- transpiled_start -= 1
139
- children = ",".join(child.transpiled_content for child in self.children)
140
- oneline_offset = len(str(self.one_line))
141
- transpiled_start += 10 + oneline_offset + 12 + 1 # ],oneline= + oneline + ,attributes= + {
133
+ self.transpiled_content = f'f"<{self.name}'
134
+
142
135
  for attribute in self.attributes.values():
143
- attribute.transpile(transpiled_start)
144
- transpiled_start = attribute.transpiled_end + 1
145
- transpiled_start -= 1
136
+ self.transpiled_content += " "
137
+ attribute.transpile(self.transpiled_start + len(self.transpiled_content))
138
+ self.transpiled_content += attribute.transpiled_content
146
139
 
147
- attributes = "{" + ",".join(attribute.transpiled_content for attribute in self.attributes.values()) + "}"
148
- self.transpiled_content = self.__element_template__.format(self.name, children, self.one_line, attributes)
140
+ if self.one_line:
141
+ self.transpiled_content += ' />"'
142
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
143
+ return
144
+
145
+ self.transpiled_content += '>"'
146
+
147
+ for child in self.children:
148
+ self.transpiled_content += "+"
149
+ if isinstance(child, ASTInterpolation):
150
+ self.transpiled_content += "str("
151
+ child.transpile(self.transpiled_start + len(self.transpiled_content))
152
+ self.transpiled_content += child.transpiled_content
153
+ if isinstance(child, ASTInterpolation):
154
+ self.transpiled_content += ")"
155
+
156
+ self.transpiled_content += f'+"</{self.name}>"'
149
157
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
150
158
 
151
159
  def map_offset(self, offset: int) -> int | None:
@@ -184,18 +192,18 @@ class ASTControlNode[T: (ASTHTMLElement, ASTComponent)]:
184
192
  transpiled_start: int = field(init=False)
185
193
  transpiled_end: int = field(init=False)
186
194
 
187
- __for_template__: str = "sequence([{} for {}])"
188
- __if_template__: str = "{} if {} else ''"
195
+ __for_template__: str = "''.join(str({}) for {})"
196
+ __if_template__: str = "({} if {} else '')"
189
197
 
190
198
  def transpile(self, transpiled_start: int) -> None:
191
199
  self.transpiled_start = transpiled_start
192
200
  if self.for_interpolation is not None:
193
- self.child.transpile(transpiled_start + 10) # sequence([
194
- self.for_interpolation.transpile(self.child.transpiled_end + 5) # child + for
201
+ self.child.transpile(transpiled_start + 12) # ''.join(str(
202
+ self.for_interpolation.transpile(self.child.transpiled_end + 6) # child + ) for
195
203
  self.transpiled_content = self.__for_template__.format(self.child.transpiled_content, self.for_interpolation.transpiled_content)
196
204
  elif self.if_interpolation is not None:
197
- self.child.transpile(transpiled_start)
198
- self.if_interpolation.transpile(self.child.transpiled_end + 4) # child + if
205
+ self.child.transpile(transpiled_start + 1) # ( before child
206
+ self.if_interpolation.transpile(self.child.transpiled_end + 4) # " if "
199
207
  self.transpiled_content = self.__if_template__.format(self.child.transpiled_content, self.if_interpolation.transpiled_content)
200
208
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
201
209
 
@@ -250,31 +258,24 @@ class ASTComponent:
250
258
  transpiled_start: int = field(init=False)
251
259
  transpiled_end: int = field(init=False)
252
260
 
253
- __template__: str = """{}([{}],{})"""
261
+ __template__: str = """{}({},{})"""
254
262
 
255
263
  def transpile(self, transpiled_start: int) -> None:
256
264
  self.transpiled_start = transpiled_start
257
265
  self.name.transpile(self.transpiled_start)
258
- transpiled_start = self.name.transpiled_end + 2
266
+ self.transpiled_content = self.name.transpiled_content + '(""'
267
+
259
268
  for child in self.children:
260
- child.transpile(transpiled_start)
261
- transpiled_start = child.transpiled_end + 1
262
- if self.children:
263
- transpiled_start -= 1
264
- children = ",".join(child.transpiled_content for child in self.children)
265
-
266
- if len(self.arguments) == 0:
267
- self.transpiled_content = self.__template__.format(self.name.transpiled_content, children, "")
268
- self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
269
- return
269
+ child.transpile(self.transpiled_start + len(self.transpiled_content))
270
+ self.transpiled_content += "+" + child.transpiled_content
270
271
 
271
- transpiled_start += 2
272
- for attribute in self.arguments.values():
273
- attribute.transpile(transpiled_start)
274
- transpiled_start = attribute.transpiled_end + 1
272
+ self.transpiled_content += ","
275
273
 
276
- attributes = ",".join(attribute.transpiled_content for attribute in self.arguments.values())
277
- self.transpiled_content = self.__template__.format(self.name.transpiled_content, children, attributes)
274
+ for argument in self.arguments.values():
275
+ argument.transpile(self.transpiled_start + len(self.transpiled_content))
276
+ self.transpiled_content += argument.transpiled_content + ","
277
+
278
+ self.transpiled_content += ")"
278
279
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
279
280
 
280
281
  def map_offset(self, offset: int) -> int | None:
@@ -452,24 +453,23 @@ class ASTHTMLAttribute:
452
453
  transpiled_start: int = field(init=False)
453
454
  transpiled_end: int = field(init=False)
454
455
 
455
- __template__: str = '"{}":{}'
456
-
457
456
  def transpile(self, transpiled_start: int) -> None:
458
457
  self.transpiled_start = transpiled_start
458
+ self.transpiled_content = "{" + f"attribute_to_string('{self.name}',"
459
459
 
460
460
  if self.string_literal is not None:
461
- escaped_literal = self.string_literal.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r").replace('"', '"')
462
- self.transpiled_content = self.__template__.format(self.name, escaped_literal)
461
+ escaped_literal = self.string_literal.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r")
462
+ self.transpiled_content += escaped_literal + ")}"
463
463
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
464
464
  return
465
465
 
466
466
  if self.interpolation is None:
467
- self.transpiled_content = self.__template__.format(self.name, "None")
467
+ self.transpiled_content = f'"{self.name}"'
468
468
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
469
469
  return
470
470
 
471
- self.interpolation.transpile(self.transpiled_start + 1 + len(self.name) + 2) # " + name + ":
472
- self.transpiled_content = self.__template__.format(self.name, self.interpolation.transpiled_content)
471
+ self.interpolation.transpile(self.transpiled_start + len(self.transpiled_content))
472
+ self.transpiled_content += self.interpolation.transpiled_content + ")}"
473
473
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
474
474
 
475
475
  def map_offset(self, offset: int) -> int | None:
@@ -535,7 +535,7 @@ class ASTInterpolation:
535
535
 
536
536
  def map_offset(self, offset: int) -> int | None:
537
537
  if self.source_start + 2 + self.leading_whitespace <= offset <= self.source_end - 2 - self.trailing_whitespace:
538
- specific_offset = offset - self.source_start - 2 - self.leading_whitespace
538
+ specific_offset = offset - (self.source_start + self.leading_whitespace + 2)
539
539
  return self.transpiled_start + specific_offset
540
540
 
541
541
  return None
@@ -546,3 +546,24 @@ class ASTInterpolation:
546
546
  return self.source_start + specific_offset + 2 + self.leading_whitespace
547
547
 
548
548
  return None
549
+
550
+
551
+ @dataclass(slots=True)
552
+ class ASTChildrenSlot:
553
+ source_start: int = field(compare=False)
554
+ source_end: int = field(compare=False)
555
+
556
+ transpiled_content: str = field(init=False)
557
+ transpiled_start: int = field(init=False)
558
+ transpiled_end: int = field(init=False)
559
+
560
+ def transpile(self, transpiled_start: int) -> None:
561
+ self.transpiled_start = transpiled_start
562
+ self.transpiled_content = "children"
563
+ self.transpiled_end = transpiled_start + len("children")
564
+
565
+ def map_offset(self, offset: int) -> None:
566
+ return None
567
+
568
+ def unmap_offset(self, offset: int) -> None:
569
+ return None
@@ -2,6 +2,7 @@ from re import Match
2
2
  import re
3
3
 
4
4
  from fragments.ast_nodes import (
5
+ ASTChildrenSlot,
5
6
  ASTComponent,
6
7
  ASTComponentArgument,
7
8
  ASTComponentName,
@@ -22,7 +23,6 @@ from fragments.source import Source
22
23
  HTML_IDENTIFIER = r"[a-zA-Z][a-zA-Z0-9_:.-]*"
23
24
  HTML_ATTRIBUTE_NAME = r"[a-zA-Z:-_][a-zA-Z0-9_:.-]*"
24
25
  HTML_TEXT = r"([\s\S]*?)(?=<|{{)"
25
- CHILDREN_META_COMPONENT = r"<[\S]*Children\.\.\.[\S]*/>"
26
26
 
27
27
 
28
28
  class ParsingError(Exception):
@@ -103,7 +103,9 @@ def expect_fragment(source: Source) -> tuple[Source, ASTFragment]:
103
103
 
104
104
  children: list[ASTHTMLChild] = []
105
105
  while not source.remaining().startswith("</>"):
106
- source, _ = source.eat_whitespace()
106
+ source_after_whitespace, _ = source.eat_whitespace()
107
+ if source_after_whitespace.remaining().startswith("<"):
108
+ source = source_after_whitespace
107
109
  if source.remaining().startswith("</>"):
108
110
  break
109
111
  source, child = expect_child(source)
@@ -116,6 +118,12 @@ def expect_fragment(source: Source) -> tuple[Source, ASTFragment]:
116
118
  return source, ASTFragment(source_start, source_end, children)
117
119
 
118
120
 
121
+ def expect_children_slot(source: Source) -> tuple[Source, ASTChildrenSlot]:
122
+ source_start = source.offset
123
+ source = expect_string(source, "<Children... />")
124
+ return source, ASTChildrenSlot(source_start, source.offset)
125
+
126
+
119
127
  def expect_child(source: Source) -> tuple[Source, ASTHTMLChild]:
120
128
  """Any HTML / functional block that might appear as part of the fragment."""
121
129
  if source.remaining().startswith("<!DOCTYPE html>"):
@@ -126,6 +134,10 @@ def expect_child(source: Source) -> tuple[Source, ASTHTMLChild]:
126
134
  source, html_comment = expect_html_comment(source)
127
135
  return source, html_comment
128
136
 
137
+ if source.remaining().startswith("<Children... />"):
138
+ source, children_slot = expect_children_slot(source)
139
+ return source, children_slot
140
+
129
141
  if source.start_matches(r"<[A-Z]"):
130
142
  source, component = expect_component(source)
131
143
  return source, component
@@ -281,7 +293,7 @@ def expect_children(source: Source) -> tuple[Source, list[ASTHTMLChild]]:
281
293
  source, child = expect_child(source)
282
294
  children.append(child)
283
295
  source_after_whitespace, _ = source.eat_whitespace()
284
- if not source_after_whitespace.remaining().startswith("{{"):
296
+ if source_after_whitespace.remaining().startswith("<"):
285
297
  source = source_after_whitespace
286
298
  return source, children
287
299
 
@@ -0,0 +1,47 @@
1
+ import json
2
+ from typing import Any
3
+ import html
4
+
5
+
6
+ def comment(content: str) -> str:
7
+ return f"<!-- {content} -->"
8
+
9
+
10
+ def attribute_to_string(name: str, value: Any) -> str:
11
+ if value is None:
12
+ return name
13
+
14
+ if isinstance(value, tuple):
15
+ value = list(value)
16
+
17
+ if name == "className":
18
+ return className_to_string(value)
19
+
20
+ if name == "style":
21
+ return style_to_string(value)
22
+
23
+ if isinstance(value, dict) or isinstance(value, list):
24
+ value = html.escape(json.dumps(value))
25
+
26
+ if isinstance(value, bool):
27
+ value = str(value).lower()
28
+
29
+ return f'{name}="{value}"'
30
+
31
+
32
+ def className_to_string(contents: list[str] | str) -> str:
33
+ if isinstance(contents, list):
34
+ inner = " ".join(contents)
35
+ else:
36
+ inner = contents
37
+
38
+ return f'class="{inner}"'
39
+
40
+
41
+ def style_to_string(style: dict[str, str] | str) -> str:
42
+ if isinstance(style, dict):
43
+ inner = ";".join(": ".join(pair) for pair in style.items())
44
+ else:
45
+ inner = style
46
+
47
+ return f'style="{inner}"'
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  from lsprotocol import types
2
3
  from lsprotocol.types import REQUESTS, NOTIFICATIONS
3
4
  from typing import cast
@@ -0,0 +1 @@
1
+ type Children = str
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-fragments"
7
- version = "0.29"
7
+ version = "0.32"
8
8
  description = "Modern HTML template rendering in Python"
9
9
  authors = [{ name = "The Running Algorithm", email = "services@therunningalgorithm.info" }]
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fragments
3
- Version: 0.29
3
+ Version: 0.32
4
4
  Summary: Modern HTML template rendering in Python
5
5
  Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
6
  License: MIT License
@@ -41,6 +41,7 @@ python_fragments.egg-info/dependency_links.txt
41
41
  python_fragments.egg-info/entry_points.txt
42
42
  python_fragments.egg-info/requires.txt
43
43
  python_fragments.egg-info/top_level.txt
44
+ tests/test_end_to_end.py
44
45
  tests/test_grammar.py
45
46
  tests/test_html_elements.py
46
47
  tests/test_source_map.py
@@ -0,0 +1,183 @@
1
+ from fragments.grammar import expect_module
2
+ from fragments.source import Source
3
+
4
+
5
+ def render(source_str: str, **variables: object) -> str:
6
+ """Transpile a fragment source string, execute it, and return the result."""
7
+ source = Source.from_string(f"result = {source_str.strip()}")
8
+ _, module = expect_module(source)
9
+ module.transpile(0)
10
+ namespace: dict[str, object] = dict(variables)
11
+ exec(module.transpiled_content, namespace)
12
+ return str(namespace["result"])
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Basic elements
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def test_single_element():
21
+ assert render("<><h1>Hello</h1></>") == "<h1>Hello</h1>"
22
+
23
+
24
+ def test_multiple_siblings():
25
+ assert render("<><h1>Title</h1><p>Body</p></>") == "<h1>Title</h1><p>Body</p>"
26
+
27
+
28
+ def test_nested_elements():
29
+ assert render("<><div><p>text</p></div></>") == "<div><p>text</p></div>"
30
+
31
+
32
+ def test_self_closing_element():
33
+ assert render("<><br /></>") == "<br />"
34
+
35
+
36
+ def test_empty_fragment():
37
+ assert render('<></>') == ""
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Interpolation
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def test_interpolation():
46
+ assert render("<><p>{{ name }}</p></>", name="World") == "<p>World</p>"
47
+
48
+
49
+ def test_multiple_interpolations():
50
+ assert render("<><p>{{ a }} and {{ b }}</p></>", a="foo", b="bar") == "<p>foo and bar</p>"
51
+
52
+
53
+ def test_text_space_before_element():
54
+ assert render("<>Icon: <i></i></>") == "Icon: <i></i>"
55
+
56
+
57
+ def test_text_space_after_element():
58
+ assert render("<><i></i> text</>") == "<i></i> text"
59
+
60
+
61
+ def test_text_space_around_element():
62
+ assert render("<>before <i>em</i> after</>") == "before <i>em</i> after"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Control flow — if
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def test_if_true():
71
+ assert render("<><p if={{ cond }}>yes</p></>", cond=True) == "<p>yes</p>"
72
+
73
+
74
+ def test_if_false():
75
+ assert render("<><p if={{ cond }}>yes</p></>", cond=False) == ""
76
+
77
+
78
+ def test_if_true_with_sibling():
79
+ result = render("<><p if={{ cond }}>yes</p><p>always</p></>", cond=True)
80
+ assert result == "<p>yes</p><p>always</p>"
81
+
82
+
83
+ def test_if_false_with_sibling():
84
+ result = render("<><p if={{ cond }}>yes</p><p>always</p></>", cond=False)
85
+ assert result == "<p>always</p>"
86
+
87
+
88
+ def test_if_true_with_preceding_sibling():
89
+ result = render("<><p>always</p><p if={{ cond }}>maybe</p></>", cond=True)
90
+ assert result == "<p>always</p><p>maybe</p>"
91
+
92
+
93
+ def test_if_false_with_preceding_sibling():
94
+ result = render("<><p>always</p><p if={{ cond }}>maybe</p></>", cond=False)
95
+ assert result == "<p>always</p>"
96
+
97
+
98
+ def test_if_between_siblings():
99
+ result = render("<><p>before</p><p if={{ cond }}>maybe</p><p>after</p></>", cond=True)
100
+ assert result == "<p>before</p><p>maybe</p><p>after</p>"
101
+
102
+ result = render("<><p>before</p><p if={{ cond }}>maybe</p><p>after</p></>", cond=False)
103
+ assert result == "<p>before</p><p>after</p>"
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Control flow — for
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def test_for_loop():
112
+ result = render("<><li for={{ item in items }}>{{ item }}</li></>", items=["a", "b", "c"])
113
+ assert result == "<li>a</li><li>b</li><li>c</li>"
114
+
115
+
116
+ def test_for_loop_with_sibling():
117
+ result = render("<><h1>List</h1><li for={{ item in items }}>{{ item }}</li></>", items=["x", "y"])
118
+ assert result == "<h1>List</h1><li>x</li><li>y</li>"
119
+
120
+
121
+ def test_for_loop_empty():
122
+ assert render("<><li for={{ item in items }}>{{ item }}</li></>", items=[]) == ""
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Components
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def test_component_no_children():
131
+ def Badge(children: str, label: str) -> str:
132
+ return f"<span>{label}</span>"
133
+
134
+ result = render("<><Badge label=\"hi\" /></>", Badge=Badge)
135
+ assert result == "<span>hi</span>"
136
+
137
+
138
+ def test_component_with_children():
139
+ from fragments.types import Children
140
+
141
+ def Wrapper(children: Children) -> str:
142
+ return f"<div>{children}</div>"
143
+
144
+ result = render("<><Wrapper><p>content</p></Wrapper></>", Wrapper=Wrapper)
145
+ assert result == "<div><p>content</p></div>"
146
+
147
+
148
+ def test_component_children_pre_joined():
149
+ from fragments.types import Children
150
+
151
+ def Wrapper(children: Children) -> str:
152
+ return f"<div>{children}</div>"
153
+
154
+ result = render(
155
+ "<><Wrapper><p>one</p><p>two</p></Wrapper></>",
156
+ Wrapper=Wrapper,
157
+ )
158
+ assert result == "<div><p>one</p><p>two</p></div>"
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Special attributes — className and style
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ def test_interpolated_classname_list():
167
+ result = render("<><div className={{ classes }}>text</div></>", classes=["foo", "bar"])
168
+ assert result == '<div class="foo bar">text</div>'
169
+
170
+
171
+ def test_interpolated_classname_string():
172
+ result = render("<><div className={{ classes }}>text</div></>", classes="foo bar")
173
+ assert result == '<div class="foo bar">text</div>'
174
+
175
+
176
+ def test_interpolated_style_dict():
177
+ result = render("<><div style={{ styles }}>text</div></>", styles={"color": "red", "font-size": "12px"})
178
+ assert result == '<div style="color: red;font-size: 12px">text</div>'
179
+
180
+
181
+ def test_interpolated_style_string():
182
+ result = render("<><div style={{ styles }}>text</div></>", styles="color: red")
183
+ assert result == '<div style="color: red">text</div>'
@@ -2,6 +2,7 @@ import pytest
2
2
 
3
3
  from fragments import grammar
4
4
  from fragments.ast_nodes import (
5
+ ASTChildrenSlot,
5
6
  ASTComponent,
6
7
  ASTComponentArgument,
7
8
  ASTComponentName,
@@ -367,6 +368,53 @@ def test_lowercase_not_parsed_as_component():
367
368
  assert isinstance(fragment.children[0], ASTHTMLElement)
368
369
 
369
370
 
371
+ # ---------------------------------------------------------------------------
372
+ # Children slot
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def test_children_slot_parses():
377
+ source = Source.from_string("<><Children... /></>")
378
+ source, fragment = grammar.expect_fragment(source)
379
+ assert source.at_end()
380
+ assert _transpiled(fragment) == _transpiled(ASTFragment(-1, -1, [
381
+ ASTChildrenSlot(-1, -1)
382
+ ]))
383
+
384
+
385
+ def test_children_slot_transpiles_to_children():
386
+ source = Source.from_string("<><Children... /></>")
387
+ _, fragment = grammar.expect_fragment(source)
388
+ fragment.transpile(0)
389
+ slot = fragment.children[0]
390
+ assert isinstance(slot, ASTChildrenSlot)
391
+ assert slot.transpiled_content == "children"
392
+
393
+
394
+ def test_children_slot_multiple_in_one_fragment():
395
+ source = Source.from_string("<><Children... /><Children... /></>")
396
+ source, fragment = grammar.expect_fragment(source)
397
+ assert source.at_end()
398
+ assert len(fragment.children) == 2
399
+ assert all(isinstance(child, ASTChildrenSlot) for child in fragment.children)
400
+
401
+
402
+ def test_children_slot_not_confused_with_component():
403
+ source = Source.from_string("<><Children /></>")
404
+ source, fragment = grammar.expect_fragment(source)
405
+ assert source.at_end()
406
+ assert isinstance(fragment.children[0], ASTComponent)
407
+
408
+
409
+ def test_children_slot_inside_element():
410
+ source = Source.from_string("<><div><Children... /></div></>")
411
+ source, fragment = grammar.expect_fragment(source)
412
+ assert source.at_end()
413
+ element = fragment.children[0]
414
+ assert isinstance(element, ASTHTMLElement)
415
+ assert isinstance(element.children[0], ASTChildrenSlot)
416
+
417
+
370
418
  # ---------------------------------------------------------------------------
371
419
  # HTML text
372
420
  # ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- from fragments.html.elements import el, className_to_string
1
+ from fragments.html.elements import className_to_string
2
2
 
3
3
 
4
4
  def test_className_list_joined_with_spaces():
@@ -19,13 +19,3 @@ def test_className_single_item_list():
19
19
  def test_className_string_passthrough():
20
20
  result = className_to_string("already-a-string")
21
21
  assert result == 'class="already-a-string"'
22
-
23
-
24
- def test_el_className_list_attribute():
25
- result = el("div", ["content"], False, {"className": ["foo", "bar"]})
26
- assert result == '<div class="foo bar">content</div>'
27
-
28
-
29
- def test_el_className_empty_list():
30
- result = el("div", [], True, {"className": []})
31
- assert result == '<div class="" />'
@@ -1,8 +1,6 @@
1
1
  from fragments.lsp.file_state import FileState
2
2
  from lsprotocol import types
3
-
4
- IMPORT_PREFIX = "from fragments.html.elements import el, sequence, comment\n"
5
- IMPORT_PREFIX_LEN = len(IMPORT_PREFIX)
3
+ from fragments.ast_nodes import IMPORT_PREFIX_LEN
6
4
 
7
5
 
8
6
  def make_state(source_str: str) -> FileState:
@@ -60,19 +58,22 @@ def test_no_fragments_position_mapping():
60
58
 
61
59
 
62
60
  def test_interpolation_is_mappable():
63
- source = "<>\n <p>{{ title }}</p>\n</>"
61
+ source = "<>\n <p>{{ title in titles }}</p>\n</>"
64
62
  state = make_state(source)
65
63
  interp_start = source.index("{{")
66
64
  # expression 'title' starts after '{{ ' (2 + 1 space)
67
65
  expr_start = interp_start + 3
68
- expr = "title"
66
+ expr = "title in titles"
69
67
 
70
68
  # Characters of the expression are mappable (first char excluded by exclusive-start).
71
69
  for k in range(1, len(expr)):
72
70
  t = state.ast_module.map_offset(expr_start + k)
73
71
  assert t is not None, f"offset {expr_start + k} should be mappable"
72
+ assert state.ast_module.map_offset(expr_start + k - 1) == t - 1
74
73
  assert state.transpiled[t] == expr[k]
75
74
 
75
+ # Characters in the white space after an expression are not mappable
76
+
76
77
  # Round-trip
77
78
  for k in range(1, len(expr)):
78
79
  t = state.ast_module.map_offset(expr_start + k)
@@ -1,73 +0,0 @@
1
- import json
2
- from typing import Any
3
- from fragments.types import Children
4
- import html
5
-
6
-
7
- def sequence(children: Children) -> str:
8
- return "".join(str(child) for child in children)
9
-
10
-
11
- def el(
12
- name: str,
13
- children: Children,
14
- oneline: bool,
15
- attributes: dict[str, Any],
16
- ) -> str:
17
- tag_contents = [
18
- name,
19
- className_to_string(attributes.pop("className")) if "className" in attributes else None,
20
- style_to_string(attributes.pop("style")) if "style" in attributes else None,
21
- attributes_to_string(attributes) if attributes else None,
22
- ]
23
- tag_contents = [item for item in tag_contents if item is not None]
24
- tag_contents_string = " ".join(tag_contents)
25
-
26
- if oneline:
27
- return f"""<{tag_contents_string} />"""
28
-
29
- children_string = sequence(children)
30
-
31
- return f"""<{tag_contents_string}>{children_string}</{name}>"""
32
-
33
-
34
- def comment(content: str) -> str:
35
- return f"<!-- {content} -->"
36
-
37
-
38
- def attributes_to_string(attributes: dict[str, Any]) -> str:
39
- return " ".join(attribute_to_string(name, value) for name, value in attributes.items())
40
-
41
-
42
- def attribute_to_string(name: str, value: Any) -> str:
43
- if value is None:
44
- return name
45
-
46
- if isinstance(value, tuple):
47
- value = list(value)
48
-
49
- if isinstance(value, dict) or isinstance(value, list):
50
- value = html.escape(json.dumps(value))
51
-
52
- if isinstance(value, bool):
53
- value = str(value).lower()
54
-
55
- return f'{name}="{value}"'
56
-
57
-
58
- def className_to_string(contents: list[str] | str) -> str:
59
- if isinstance(contents, list):
60
- inner = " ".join(contents)
61
- else:
62
- inner = contents
63
-
64
- return f'class="{inner}"'
65
-
66
-
67
- def style_to_string(style: dict[str, str] | str) -> str:
68
- if isinstance(style, dict):
69
- inner = ";".join(": ".join(pair) for pair in style.items())
70
- else:
71
- inner = style
72
-
73
- return f'style="{inner}"'
@@ -1,9 +0,0 @@
1
- from typing import Protocol
2
-
3
-
4
- class Stringable(Protocol):
5
- def __str__(self) -> str: ...
6
-
7
-
8
- type Child = str | Stringable
9
- type Children = list[Child]
File without changes