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.
- python_fragments-0.1/PKG-INFO +55 -0
- python_fragments-0.1/README.md +41 -0
- python_fragments-0.1/fragments/__init__.py +0 -0
- python_fragments-0.1/fragments/ast_nodes.py +237 -0
- python_fragments-0.1/fragments/cli.py +20 -0
- python_fragments-0.1/fragments/grammar.py +206 -0
- python_fragments-0.1/fragments/html/__init__.py +0 -0
- python_fragments-0.1/fragments/html/elements.py +54 -0
- python_fragments-0.1/fragments/loader.py +32 -0
- python_fragments-0.1/fragments/lsp/__init__.py +0 -0
- python_fragments-0.1/fragments/lsp/completion.py +79 -0
- python_fragments-0.1/fragments/lsp/definition.py +49 -0
- python_fragments-0.1/fragments/lsp/file_state.py +109 -0
- python_fragments-0.1/fragments/lsp/hover.py +44 -0
- python_fragments-0.1/fragments/lsp/lifecycle.py +116 -0
- python_fragments-0.1/fragments/lsp/pyright.py +94 -0
- python_fragments-0.1/fragments/lsp/rename.py +106 -0
- python_fragments-0.1/fragments/lsp/semantic_tokens.py +42 -0
- python_fragments-0.1/fragments/lsp/server.py +129 -0
- python_fragments-0.1/fragments/source.py +26 -0
- python_fragments-0.1/fragments/transpiler.py +10 -0
- python_fragments-0.1/pyproject.toml +34 -0
- python_fragments-0.1/python_fragments.egg-info/PKG-INFO +55 -0
- python_fragments-0.1/python_fragments.egg-info/SOURCES.txt +30 -0
- python_fragments-0.1/python_fragments.egg-info/dependency_links.txt +1 -0
- python_fragments-0.1/python_fragments.egg-info/entry_points.txt +3 -0
- python_fragments-0.1/python_fragments.egg-info/requires.txt +7 -0
- python_fragments-0.1/python_fragments.egg-info/top_level.txt +1 -0
- python_fragments-0.1/setup.cfg +4 -0
- python_fragments-0.1/tests/test_grammar.py +86 -0
- python_fragments-0.1/tests/test_pyright.py +126 -0
- 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
|