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.
- {python_fragments-0.29 → python_fragments-0.32}/PKG-INFO +1 -1
- {python_fragments-0.29 → python_fragments-0.32}/fragments/ast_nodes.py +85 -64
- {python_fragments-0.29 → python_fragments-0.32}/fragments/grammar.py +15 -3
- python_fragments-0.32/fragments/html/elements.py +47 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/hover.py +1 -0
- python_fragments-0.32/fragments/types.py +1 -0
- {python_fragments-0.29 → python_fragments-0.32}/pyproject.toml +1 -1
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/PKG-INFO +1 -1
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/SOURCES.txt +1 -0
- python_fragments-0.32/tests/test_end_to_end.py +183 -0
- {python_fragments-0.29 → python_fragments-0.32}/tests/test_grammar.py +48 -0
- {python_fragments-0.29 → python_fragments-0.32}/tests/test_html_elements.py +1 -11
- {python_fragments-0.29 → python_fragments-0.32}/tests/test_source_map.py +6 -5
- python_fragments-0.29/fragments/html/elements.py +0 -73
- python_fragments-0.29/fragments/types.py +0 -9
- {python_fragments-0.29 → python_fragments-0.32}/LICENSE +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/README.md +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/__init__.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/cli.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/html/__init__.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/loader.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/__init__.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/based_proxy.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/__init__.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/code_actions.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/completion.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/definition.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/diagnostics.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/document_highlight.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/document_symbols.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/folding_range.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/inlay_hints.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/lifecycle.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/references.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/rename.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/semantic_tokens.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/signature_help.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/file_state.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/message_queue.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/__init__.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/capability.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/configuration.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/pyright_notification_handlers/diagnostics.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/types.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/source.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/fragments/transpiler.py +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/dependency_links.txt +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/entry_points.txt +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/requires.txt +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/top_level.txt +0 -0
- {python_fragments-0.29 → python_fragments-0.32}/setup.cfg +0 -0
|
@@ -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 = "
|
|
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
|
-
|
|
26
|
+
self.transpiled_content = IMPORT_PREFIX
|
|
27
|
+
|
|
24
28
|
for child in self.children:
|
|
25
|
-
child.transpile(transpiled_start)
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
136
|
+
self.transpiled_content += " "
|
|
137
|
+
attribute.transpile(self.transpiled_start + len(self.transpiled_content))
|
|
138
|
+
self.transpiled_content += attribute.transpiled_content
|
|
146
139
|
|
|
147
|
-
|
|
148
|
-
|
|
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 = "
|
|
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 +
|
|
194
|
-
self.for_interpolation.transpile(self.child.transpiled_end +
|
|
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) #
|
|
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
|
-
|
|
266
|
+
self.transpiled_content = self.name.transpiled_content + '(""'
|
|
267
|
+
|
|
259
268
|
for child in self.children:
|
|
260
|
-
child.transpile(transpiled_start)
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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")
|
|
462
|
-
self.transpiled_content
|
|
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.
|
|
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 +
|
|
472
|
-
self.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
|
|
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
|
-
|
|
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
|
|
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}"'
|
|
@@ -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.
|
|
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"
|
|
@@ -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
|
|
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}"'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/completion.py
RENAMED
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/definition.py
RENAMED
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/inlay_hints.py
RENAMED
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/lifecycle.py
RENAMED
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/references.py
RENAMED
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/fragments/lsp/client_message_handlers/rename.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fragments-0.29 → python_fragments-0.32}/python_fragments.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|