python-fragments 0.1__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 (32) hide show
  1. python_fragments-0.1/PKG-INFO +55 -0
  2. python_fragments-0.1/README.md +41 -0
  3. python_fragments-0.1/fragments/__init__.py +0 -0
  4. python_fragments-0.1/fragments/ast_nodes.py +237 -0
  5. python_fragments-0.1/fragments/cli.py +20 -0
  6. python_fragments-0.1/fragments/grammar.py +206 -0
  7. python_fragments-0.1/fragments/html/__init__.py +0 -0
  8. python_fragments-0.1/fragments/html/elements.py +54 -0
  9. python_fragments-0.1/fragments/loader.py +32 -0
  10. python_fragments-0.1/fragments/lsp/__init__.py +0 -0
  11. python_fragments-0.1/fragments/lsp/completion.py +79 -0
  12. python_fragments-0.1/fragments/lsp/definition.py +49 -0
  13. python_fragments-0.1/fragments/lsp/file_state.py +109 -0
  14. python_fragments-0.1/fragments/lsp/hover.py +44 -0
  15. python_fragments-0.1/fragments/lsp/lifecycle.py +116 -0
  16. python_fragments-0.1/fragments/lsp/pyright.py +94 -0
  17. python_fragments-0.1/fragments/lsp/rename.py +106 -0
  18. python_fragments-0.1/fragments/lsp/semantic_tokens.py +42 -0
  19. python_fragments-0.1/fragments/lsp/server.py +129 -0
  20. python_fragments-0.1/fragments/source.py +26 -0
  21. python_fragments-0.1/fragments/transpiler.py +10 -0
  22. python_fragments-0.1/pyproject.toml +34 -0
  23. python_fragments-0.1/python_fragments.egg-info/PKG-INFO +55 -0
  24. python_fragments-0.1/python_fragments.egg-info/SOURCES.txt +30 -0
  25. python_fragments-0.1/python_fragments.egg-info/dependency_links.txt +1 -0
  26. python_fragments-0.1/python_fragments.egg-info/entry_points.txt +3 -0
  27. python_fragments-0.1/python_fragments.egg-info/requires.txt +7 -0
  28. python_fragments-0.1/python_fragments.egg-info/top_level.txt +1 -0
  29. python_fragments-0.1/setup.cfg +4 -0
  30. python_fragments-0.1/tests/test_grammar.py +86 -0
  31. python_fragments-0.1/tests/test_pyright.py +126 -0
  32. python_fragments-0.1/tests/test_source_map.py +161 -0
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-fragments
3
+ Version: 0.1
4
+ Summary: HTML template rendering in Python
5
+ Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: lsp
10
+ Requires-Dist: basedpyright>=1.39.0; extra == "lsp"
11
+ Requires-Dist: pygls>=1.3.0; extra == "lsp"
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.4.1; extra == "dev"
14
+
15
+ # Python Fragments
16
+
17
+ > **This package is in early development and not yet stable. The API may change without notice between releases.**
18
+
19
+ Production-ready, modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
20
+
21
+ ```python
22
+ from fragments import loader # isort: skip
23
+
24
+ from fastapi import FastAPI
25
+ from fastapi.responses import HTMLResponse
26
+ from components import Layout, PostCard
27
+
28
+ app = FastAPI()
29
+
30
+ POSTS = [...]
31
+
32
+ @app.get("/", response_class=HTMLResponse)
33
+ async def index() -> str:
34
+ published = [p for p in POSTS if p.published]
35
+ return <>
36
+ <Layout title="My Blog">
37
+ <h1>Latest Posts</h1>
38
+ <PostCard for={{ post in published }} post={{ post }} />
39
+ </Layout>
40
+ </>
41
+ ```
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install python-fragments
47
+ ```
48
+
49
+ Register the loader at your application's entry point, before importing any modules that contain fragments:
50
+
51
+ ```python
52
+ from fragments import loader # isort: skip
53
+ ```
54
+
55
+ Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
@@ -0,0 +1,41 @@
1
+ # Python Fragments
2
+
3
+ > **This package is in early development and not yet stable. The API may change without notice between releases.**
4
+
5
+ Production-ready, modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
6
+
7
+ ```python
8
+ from fragments import loader # isort: skip
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.responses import HTMLResponse
12
+ from components import Layout, PostCard
13
+
14
+ app = FastAPI()
15
+
16
+ POSTS = [...]
17
+
18
+ @app.get("/", response_class=HTMLResponse)
19
+ async def index() -> str:
20
+ published = [p for p in POSTS if p.published]
21
+ return <>
22
+ <Layout title="My Blog">
23
+ <h1>Latest Posts</h1>
24
+ <PostCard for={{ post in published }} post={{ post }} />
25
+ </Layout>
26
+ </>
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install python-fragments
33
+ ```
34
+
35
+ Register the loader at your application's entry point, before importing any modules that contain fragments:
36
+
37
+ ```python
38
+ from fragments import loader # isort: skip
39
+ ```
40
+
41
+ Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
File without changes
@@ -0,0 +1,237 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Sequence
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class ASTModule:
7
+ source_start: int = field(compare=False)
8
+ source_end: int = field(compare=False)
9
+
10
+ children: list["ASTPython | ASTFragment"]
11
+
12
+ transpiled_content: str = field(init=False)
13
+ transpiled_start: int = field(init=False)
14
+ transpiled_end: int = field(init=False)
15
+
16
+ __template__: str = "from fragments.html.elements import el, sequence, comment\n{}"
17
+
18
+ def transpile(self, transpiled_start: int = 0) -> None:
19
+ self.transpiled_start = transpiled_start
20
+ transpiled_start += len(self.__template__) - 2
21
+ for child in self.children:
22
+ child.transpile(transpiled_start)
23
+ transpiled_start = child.transpiled_end
24
+
25
+ children: str = "".join(child.transpiled_content for child in self.children)
26
+ self.transpiled_content = self.__template__.format(children)
27
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class ASTPython:
32
+ source_start: int = field(compare=False)
33
+ source_end: int = field(compare=False)
34
+ content: str
35
+
36
+ transpiled_content: str = field(init=False)
37
+ transpiled_start: int = field(init=False)
38
+ transpiled_end: int = field(init=False)
39
+
40
+ def transpile(self, transpiled_start: int) -> None:
41
+ self.transpiled_start = transpiled_start
42
+ self.transpiled_content = self.content
43
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class ASTFragment:
48
+ source_start: int = field(compare=False)
49
+ source_end: int = field(compare=False)
50
+
51
+ children: list["ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation"]
52
+
53
+ transpiled_content: str = field(init=False)
54
+ transpiled_start: int = field(init=False)
55
+ transpiled_end: int = field(init=False)
56
+
57
+ __template__: str = """sequence([{}])"""
58
+
59
+ def transpile(self, transpiled_start: int) -> None:
60
+ self.transpiled_start = transpiled_start
61
+ transpiled_start += 10
62
+ for child in self.children:
63
+ child.transpile(transpiled_start)
64
+ transpiled_start = child.transpiled_end + 1
65
+ transpiled_start -= 1
66
+
67
+ self.transpiled_content = self.__template__.format(",".join(child.transpiled_content for child in self.children))
68
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class ASTHTMLElement:
73
+ source_start: int = field(compare=False)
74
+ source_end: int = field(compare=False)
75
+
76
+ name: str
77
+ attributes: dict[str, "ASTHTMLAttribute"]
78
+ if_attribute: "ASTInterpolation | None"
79
+ for_attribute: "ASTInterpolation | None"
80
+ children: Sequence["ASTHTMLElement | ASTHTMLText | ASTInterpolation"]
81
+ one_line: bool
82
+
83
+ transpiled_content: str = field(init=False)
84
+ transpiled_start: int = field(init=False)
85
+ transpiled_end: int = field(init=False)
86
+
87
+ __for_template__: str = """sequence([{} for {}])"""
88
+ __if_template__: str = """{} if {} else ''"""
89
+ __element_template__: str = """el("{}",[{}],oneline={},{})"""
90
+ __component_template__: str = """{}([{}],{})"""
91
+
92
+ def transpile(self, transpiled_start: int) -> None:
93
+ self.transpiled_start = transpiled_start
94
+
95
+ if self.for_attribute is not None:
96
+ transpiled_start += 10
97
+
98
+ if self.name[0].capitalize() == self.name[0]:
99
+ self._transpile_component_call(transpiled_start)
100
+ else:
101
+ self._transpile_element_call(transpiled_start)
102
+
103
+ if self.if_attribute is not None:
104
+ self.if_attribute.transpile(self.transpiled_end + 4)
105
+ self.transpiled_content = self.__if_template__.format(self.transpiled_content, self.if_attribute.transpiled_content)
106
+ self.transpiled_end = self.if_attribute.transpiled_end + 8
107
+ elif self.for_attribute is not None:
108
+ self.for_attribute.transpile(self.transpiled_end + 5)
109
+ self.transpiled_content = self.__for_template__.format(self.transpiled_content, self.for_attribute.transpiled_content)
110
+ self.transpiled_end = self.for_attribute.transpiled_end + 2
111
+
112
+ def _transpile_element_call(self, start: int) -> None:
113
+ transpiled_start = start + len(self.name) + 7
114
+ for child in self.children:
115
+ child.transpile(transpiled_start)
116
+ transpiled_start = child.transpiled_end + 1
117
+ transpiled_start -= 1
118
+ children = ",".join(child.transpiled_content for child in self.children)
119
+
120
+ oneline_offset = len(str(self.one_line))
121
+
122
+ transpiled_start += 10 + oneline_offset + 1
123
+
124
+ for attribute in self.attributes.values():
125
+ attribute.transpile(transpiled_start)
126
+ transpiled_start = attribute.transpiled_end + 1
127
+ transpiled_start -= 1
128
+
129
+ attributes = ",".join(attribute.transpiled_content for attribute in self.attributes.values())
130
+ self.transpiled_content = self.__element_template__.format(self.name, children, self.one_line, attributes)
131
+ self.transpiled_end = start + len(self.transpiled_content)
132
+
133
+ def _transpile_component_call(self, start: int) -> None:
134
+ transpiled_start = start + len(self.name) + 2
135
+ for child in self.children:
136
+ child.transpile(transpiled_start)
137
+ transpiled_start = child.transpiled_end + 1
138
+ transpiled_start -= 1
139
+ children = ",".join(child.transpiled_content for child in self.children)
140
+
141
+ if len(self.attributes) == 0:
142
+ self.transpiled_content = self.__component_template__.format(self.name, children, "")
143
+ self.transpiled_end = start + len(self.transpiled_content)
144
+ return
145
+
146
+ transpiled_start += 2
147
+ for attribute in self.attributes.values():
148
+ attribute.transpile(transpiled_start)
149
+ transpiled_start = attribute.transpiled_end + 1
150
+
151
+ attributes = ",".join(attribute.transpiled_content for attribute in self.attributes.values())
152
+ self.transpiled_content = self.__component_template__.format(self.name, children, attributes)
153
+ self.transpiled_end = start + len(self.transpiled_content)
154
+
155
+
156
+ @dataclass(slots=True)
157
+ class ASTHTMLComment:
158
+ source_start: int = field(compare=False)
159
+ source_end: int = field(compare=False)
160
+
161
+ content: str
162
+
163
+ transpiled_content: str = field(init=False)
164
+ transpiled_start: int = field(init=False)
165
+ transpiled_end: int = field(init=False)
166
+
167
+ __template__: str = """comment("{}")"""
168
+
169
+ def transpile(self, transpiled_start: int) -> None:
170
+ self.transpiled_start = transpiled_start
171
+ self.transpiled_content = self.__template__.format(self.content)
172
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
173
+
174
+
175
+ @dataclass(slots=True)
176
+ class ASTHTMLAttribute:
177
+ source_start: int = field(compare=False)
178
+ source_end: int = field(compare=False)
179
+
180
+ name: str
181
+ value: str | None
182
+ interpolation: "ASTInterpolation | None"
183
+
184
+ transpiled_content: str = field(init=False)
185
+ transpiled_start: int = field(init=False)
186
+ transpiled_end: int = field(init=False)
187
+
188
+ __value_template__: str = '{}="{}"'
189
+ __interpolation_template__: str = "{}={}"
190
+
191
+ def transpile(self, transpiled_start: int) -> None:
192
+ self.transpiled_start = transpiled_start
193
+
194
+ if self.value is not None:
195
+ self.transpiled_content = self.__value_template__.format(self.name, self.value)
196
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
197
+ return
198
+
199
+ assert self.interpolation is not None
200
+ self.interpolation.transpile(self.transpiled_start + len(self.name) + 1)
201
+ self.transpiled_content = self.__interpolation_template__.format(self.name, self.interpolation.transpiled_content)
202
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
203
+
204
+
205
+ @dataclass(slots=True)
206
+ class ASTHTMLText:
207
+ source_start: int = field(compare=False)
208
+ source_end: int = field(compare=False)
209
+
210
+ text: str
211
+
212
+ transpiled_content: str = field(init=False)
213
+ transpiled_start: int = field(init=False)
214
+ transpiled_end: int = field(init=False)
215
+
216
+ __template__: str = '"{}"'
217
+
218
+ def transpile(self, transpiled_start: int) -> None:
219
+ self.transpiled_content = self.__template__.format(self.text)
220
+ self.transpiled_start = transpiled_start
221
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
222
+
223
+
224
+ @dataclass(slots=True)
225
+ class ASTInterpolation:
226
+ source_start: int = field(compare=False)
227
+ source_end: int = field(compare=False)
228
+ expression: str
229
+
230
+ transpiled_content: str = field(init=False)
231
+ transpiled_start: int = field(init=False)
232
+ transpiled_end: int = field(init=False)
233
+
234
+ def transpile(self, transpiled_start: int) -> None:
235
+ self.transpiled_content = self.expression
236
+ self.transpiled_start = transpiled_start
237
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
@@ -0,0 +1,20 @@
1
+ import argparse
2
+
3
+ from fragments import transpiler
4
+
5
+
6
+ def parse_args() -> argparse.Namespace:
7
+ parser = argparse.ArgumentParser("fragments", description="Fragments transpilation CLI tool")
8
+ parser.add_argument("--input", type=str, required=True)
9
+ return parser.parse_args()
10
+
11
+
12
+ def main():
13
+ args = parse_args()
14
+ with open(args.input, "r") as f:
15
+ source = f.read()
16
+ print(transpiler.transpile(source))
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,206 @@
1
+ from re import Match
2
+ import re
3
+
4
+ from fragments.ast_nodes import ASTFragment, ASTHTMLAttribute, ASTHTMLComment, ASTHTMLElement, ASTHTMLText, ASTInterpolation, ASTModule, ASTPython
5
+ from fragments.source import Source
6
+
7
+ PYTHON = r"([\s\S]*?)(?=<>)|[\s\S]*$"
8
+ IDENTIFIER = r"[a-zA-Z_][a-zA-Z0-9_]*"
9
+ STRING_CONTENTS = r"(.*?)(?=\")"
10
+ INTERPOLATION_EXPRESSION = r"(.*?)(?= }})"
11
+ HTML_IDENTIFIER = r"[a-zA-Z][a-zA-Z0-9_-]*"
12
+ HTML_TEXT = r"(.*?)(?=<)"
13
+
14
+
15
+ class ParsingError(Exception):
16
+ def __init__(self, message: str, source_start: int) -> None:
17
+ super().__init__(message)
18
+ self.source_start: int = source_start
19
+
20
+
21
+ def optional_regex(source: Source, pattern: str) -> tuple[Source, str | None]:
22
+ """Expect the source to start with the given pattern, return the source after the pattern and the found match (if any)."""
23
+ result: Match[str] | None = re.match(pattern, source.remaining())
24
+ if result is None:
25
+ return source, None
26
+ return source.eat(result.end()), result.group(0)
27
+
28
+
29
+ def expect_regex(source: Source, pattern: str, label: str) -> tuple[Source, str]:
30
+ """Expect the source to start with the given pattern, return the source after the pattern and the found match."""
31
+ source, result = optional_regex(source, pattern)
32
+ if result is None:
33
+ raise ParsingError(f"Expected {label}", source.offset)
34
+ return source, result
35
+
36
+
37
+ def expect_string(source: Source, string: str) -> Source:
38
+ """Expect the source to start with the given string, return the source after the string."""
39
+ if not source.remaining().startswith(string):
40
+ raise ParsingError(f"Expected {string}", source.offset)
41
+ return source.eat(len(string))
42
+
43
+
44
+ def expect_module(source: Source) -> tuple[Source, ASTModule]:
45
+ """The top level of the recursive descent grammar."""
46
+ children: list["ASTPython | ASTFragment"] = []
47
+ source_start: int = source.offset
48
+ while not source.at_end():
49
+ if source.remaining().startswith("<>"):
50
+ source, fragment = expect_fragment(source)
51
+ children.append(fragment)
52
+ else:
53
+ source, python = expect_python(source)
54
+ children.append(python)
55
+ return source, ASTModule(source_start, source.offset, children)
56
+
57
+
58
+ def expect_python(source: Source) -> tuple[Source, ASTPython]:
59
+ """Vanilla Python code."""
60
+ source_start: int = source.offset
61
+ source, python = expect_regex(source, PYTHON, "python source")
62
+ return source, ASTPython(source_start, source.offset, python)
63
+
64
+
65
+ def expect_fragment(source: Source) -> tuple[Source, ASTFragment]:
66
+ """A fragment - the top level of things created by this library."""
67
+ source_start: int = source.offset
68
+ source = expect_string(source, "<>")
69
+
70
+ children: list[ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation] = []
71
+ while not source.remaining().startswith("</>"):
72
+ source, _ = source.eat_whitespace()
73
+ if source.remaining().startswith("</>"):
74
+ break
75
+ source, child = expect_expression(source)
76
+ children.append(child)
77
+
78
+ source, _ = source.eat_whitespace()
79
+ source = expect_string(source, "</>")
80
+ source_end: int = source.offset
81
+
82
+ return source, ASTFragment(source_start, source_end, children)
83
+
84
+
85
+ def expect_expression(source: Source) -> tuple[Source, ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation]:
86
+ """Any HTML / functional block that might appear as part of the fragment."""
87
+ if source.remaining().startswith("<!--"):
88
+ source, html_comment = expect_html_comment(source)
89
+ return source, html_comment
90
+
91
+ if source.remaining().startswith("<"):
92
+ source, html_element = expect_html_element(source)
93
+ return source, html_element
94
+
95
+ if source.remaining().startswith("{{"):
96
+ source, interpolation = expect_interpolation(source)
97
+ return source, interpolation
98
+
99
+ source, html_text = expect_html_text(source)
100
+ return source, html_text
101
+
102
+
103
+ def expect_html_comment(source: Source) -> tuple[Source, ASTHTMLComment]:
104
+ source_start = source.offset
105
+ source = expect_string(source, "<!--")
106
+ source, content = expect_regex(source, r"[\s\S]*?(?=-->)", "comment content")
107
+ source = expect_string(source, "-->")
108
+ return source, ASTHTMLComment(source_start, source.offset, content)
109
+
110
+
111
+ def expect_html_element(source: Source) -> tuple[Source, ASTHTMLElement]:
112
+ """An HTML element that may appear in the fragment tree."""
113
+ element_source_start = source.offset
114
+ source = expect_string(source, "<")
115
+ source, _ = source.eat_whitespace()
116
+ source, name = expect_regex(source, HTML_IDENTIFIER, "element name")
117
+ source, _ = source.eat_whitespace()
118
+
119
+ attributes: dict[str, ASTHTMLAttribute] = {}
120
+ while not (source.remaining().startswith(">") or source.remaining().startswith("/>")):
121
+ attribute_source_start = source.offset
122
+ source, attribute_name = expect_regex(source, HTML_IDENTIFIER, "attribute name")
123
+ if not source.remaining().startswith("="):
124
+ attribute = ASTHTMLAttribute(attribute_source_start, source.offset, attribute_name, None, None)
125
+ attributes[attribute_name] = attribute
126
+ continue
127
+ source = expect_string(source, "=")
128
+
129
+ if source.remaining().startswith('"'):
130
+ source = expect_string(source, '"')
131
+ source, attribute_value = expect_regex(source, STRING_CONTENTS, "attribute value")
132
+ source = expect_string(source, '"')
133
+ attribute = ASTHTMLAttribute(attribute_source_start, source.offset, attribute_name, attribute_value, None)
134
+ attributes[attribute_name] = attribute
135
+ elif source.remaining().startswith("{{"):
136
+ source, interpolation = expect_interpolation(source)
137
+ attribute = ASTHTMLAttribute(attribute_source_start, source.offset, attribute_name, None, interpolation)
138
+ attributes[attribute_name] = attribute
139
+
140
+ source, _ = source.eat_whitespace()
141
+
142
+ if_attribute = attributes.pop("if") if "if" in attributes else None
143
+ for_attribute = attributes.pop("for") if "for" in attributes else None
144
+
145
+ if source.remaining().startswith("/>"):
146
+ source = expect_string(source, "/>")
147
+ html_element = ASTHTMLElement(
148
+ element_source_start,
149
+ source.offset,
150
+ name,
151
+ attributes,
152
+ if_attribute.interpolation if if_attribute is not None else None,
153
+ for_attribute.interpolation if for_attribute is not None else None,
154
+ [],
155
+ True,
156
+ )
157
+ return source, html_element
158
+
159
+ source = expect_string(source, ">")
160
+ source, _ = source.eat_whitespace()
161
+
162
+ children: list[ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation] = []
163
+ while not source.remaining().startswith("</"):
164
+ source, child = expect_expression(source)
165
+ children.append(child)
166
+ source, _ = source.eat_whitespace()
167
+
168
+ source = expect_string(source, "</")
169
+ source, _ = source.eat_whitespace()
170
+ source, closing_name = expect_regex(source, IDENTIFIER, "element name")
171
+
172
+ if name != closing_name:
173
+ raise ParsingError(f"Element closed ({closing_name!r}) is not the same as currently opened element ({name!r})", source.offset)
174
+
175
+ source, _ = source.eat_whitespace()
176
+ source = expect_string(source, ">")
177
+
178
+ html_element = ASTHTMLElement(
179
+ element_source_start,
180
+ source.offset,
181
+ name,
182
+ attributes,
183
+ if_attribute.interpolation if if_attribute is not None else None,
184
+ for_attribute.interpolation if for_attribute is not None else None,
185
+ children,
186
+ False,
187
+ )
188
+ return source, html_element
189
+
190
+
191
+ def expect_interpolation(source: Source) -> tuple[Source, ASTInterpolation]:
192
+ """An interpolation block."""
193
+ source_start = source.offset
194
+ source = expect_string(source, "{{")
195
+ source, _ = source.eat_whitespace()
196
+ source, expression = expect_regex(source, INTERPOLATION_EXPRESSION, "expression")
197
+ source, _ = source.eat_whitespace()
198
+ source = expect_string(source, "}}")
199
+ return source, ASTInterpolation(source_start, source.offset, expression)
200
+
201
+
202
+ def expect_html_text(source: Source) -> tuple[Source, ASTHTMLText]:
203
+ """Text as the child of an HTML expression."""
204
+ source_start = source.offset
205
+ source, text = expect_regex(source, HTML_TEXT, "text")
206
+ return source, ASTHTMLText(source_start, source.offset, text)
File without changes
@@ -0,0 +1,54 @@
1
+ from typing import Any
2
+
3
+
4
+ def sequence(children: list[str]) -> str:
5
+ return "".join(children)
6
+
7
+
8
+ def el(
9
+ name: str,
10
+ children: list[str],
11
+ oneline: bool,
12
+ **attributes: dict[str, Any],
13
+ ) -> str:
14
+ tag_contents = [
15
+ name,
16
+ classes_to_string(attributes.pop("classes")) if "classes" in attributes else None,
17
+ style_to_string(attributes.pop("style")) if "style" in attributes else None,
18
+ attributes_to_string(attributes) if attributes is not None else None,
19
+ ]
20
+ tag_contents = [item for item in tag_contents if item is not None]
21
+ tag_contents_string = " ".join(tag_contents)
22
+
23
+ if oneline:
24
+ return f"""<{tag_contents_string} />"""
25
+
26
+ children_string = "".join(str(child) for child in children)
27
+
28
+ return f"""<{tag_contents_string}>{children_string}</{name}>"""
29
+
30
+
31
+ def comment(content: str) -> str:
32
+ return f"<!-- {content} -->"
33
+
34
+
35
+ def attributes_to_string(attributes: dict[str, Any]) -> str:
36
+ return " ".join("=".join([key, f'"{value}"']) if value is not None else key for key, value in attributes.items())
37
+
38
+
39
+ def classes_to_string(classes: list[str] | str) -> str:
40
+ if isinstance(classes, list):
41
+ inner = " ".join(classes)
42
+ else:
43
+ inner = classes
44
+
45
+ return f'class="{inner}"'
46
+
47
+
48
+ def style_to_string(style: dict[str, str] | str) -> str:
49
+ if isinstance(style, dict):
50
+ inner = ";".join(": ".join(pair) for pair in style.items())
51
+ else:
52
+ inner = style
53
+
54
+ return f'style="{inner}"'
@@ -0,0 +1,32 @@
1
+ import importlib.abc
2
+ import importlib.machinery
3
+ import sys
4
+
5
+ from fragments import transpiler
6
+
7
+
8
+ class TranspilingLoader(importlib.machinery.SourceFileLoader):
9
+ def get_data(self, path: str) -> bytes:
10
+ if not path.endswith(".py"):
11
+ return super().get_data(path)
12
+
13
+ source = super().get_data(path).decode("utf-8")
14
+
15
+ if "<>" not in source:
16
+ return source.encode("utf-8")
17
+
18
+ transpiled = transpiler.transpile(source)
19
+ return transpiled.encode("utf-8")
20
+
21
+
22
+ class TranspilingFinder(importlib.abc.MetaPathFinder):
23
+ def find_spec(self, fullname: str, path, target=None):
24
+ spec = importlib.machinery.PathFinder.find_spec(fullname, path)
25
+ if spec is None or not isinstance(spec.loader, importlib.machinery.SourceFileLoader):
26
+ return None
27
+ assert spec.origin is not None
28
+ spec.loader = TranspilingLoader(fullname, spec.origin)
29
+ return spec
30
+
31
+
32
+ sys.meta_path.insert(0, TranspilingFinder())
File without changes