pyweft 0.2.0__py3-none-any.whl
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.
- pyweft/__init__.py +60 -0
- pyweft/__main__.py +4 -0
- pyweft/background.py +203 -0
- pyweft/binding.py +118 -0
- pyweft/cli.py +1006 -0
- pyweft/component.py +487 -0
- pyweft/desktop.py +606 -0
- pyweft/dialogs.py +123 -0
- pyweft/differ.py +195 -0
- pyweft/dommodel.py +63 -0
- pyweft/effects.py +191 -0
- pyweft/events.py +102 -0
- pyweft/expressions.py +184 -0
- pyweft/hotreload.py +122 -0
- pyweft/packaging.py +186 -0
- pyweft/parser.py +426 -0
- pyweft/prerender.py +121 -0
- pyweft/renderer.py +802 -0
- pyweft/routing.py +364 -0
- pyweft/static/loading.html +102 -0
- pyweft/static/runtime.js +341 -0
- pyweft/static/shell.html +693 -0
- pyweft/static/worker.js +89 -0
- pyweft/store.py +118 -0
- pyweft/styles.py +171 -0
- pyweft/vdom.py +57 -0
- pyweft-0.2.0.dist-info/METADATA +324 -0
- pyweft-0.2.0.dist-info/RECORD +31 -0
- pyweft-0.2.0.dist-info/WHEEL +4 -0
- pyweft-0.2.0.dist-info/entry_points.txt +2 -0
- pyweft-0.2.0.dist-info/licenses/LICENSE +21 -0
pyweft/parser.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Parse a ``.weft`` file into a template tree plus a code block.
|
|
2
|
+
|
|
3
|
+
File shape::
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<button @click="increment">{{ count }}</button>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<code>
|
|
10
|
+
count: int = 0
|
|
11
|
+
|
|
12
|
+
def increment(self):
|
|
13
|
+
self.count += 1
|
|
14
|
+
</code>
|
|
15
|
+
|
|
16
|
+
The ``<code>`` and ``<style>`` blocks are cut out by string scanning *before*
|
|
17
|
+
any XML parsing, because Python source is full of ``<``, ``>`` and ``&`` (and
|
|
18
|
+
CSS of ``>``) and would blow up an XML parser. Only the ``<template>`` block
|
|
19
|
+
is parsed as XML.
|
|
20
|
+
|
|
21
|
+
The template must be well-formed XML (close your tags, quote your attributes).
|
|
22
|
+
That buys us the stdlib ``xml.etree`` parser instead of a bespoke HTML5
|
|
23
|
+
tokenizer, which is a large amount of complexity to skip for a PoC.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import textwrap
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .events import parse_bind_modifiers, parse_event_spec
|
|
35
|
+
from .expressions import parse_interpolation
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TemplateError(RuntimeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------- node types
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TStatic:
|
|
47
|
+
"""Literal text, possibly with {{ }} holes."""
|
|
48
|
+
|
|
49
|
+
parts: list[tuple[str, str]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TElement:
|
|
54
|
+
tag: str
|
|
55
|
+
attrs: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
|
56
|
+
# Event spec ("click", "keydown.enter") -> handler source; see events.py.
|
|
57
|
+
events: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
bind: str | None = None
|
|
59
|
+
bind_mods: tuple[str, ...] = ()
|
|
60
|
+
key_expr: str | None = None
|
|
61
|
+
children: list[Any] = field(default_factory=list)
|
|
62
|
+
# ``:name="expr"`` bindings -- evaluated as Python, unlike ``attrs`` which
|
|
63
|
+
# interpolate to strings. On components these become typed props.
|
|
64
|
+
expr_attrs: dict[str, str] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class TIf:
|
|
69
|
+
"""branches is [(cond_src, children), ...]; a None cond is the else."""
|
|
70
|
+
|
|
71
|
+
branches: list[tuple[str | None, list[Any]]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class TFor:
|
|
76
|
+
var: str
|
|
77
|
+
iter_src: str
|
|
78
|
+
index_var: str | None = None
|
|
79
|
+
key_expr: str | None = None
|
|
80
|
+
children: list[Any] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class TSlot:
|
|
85
|
+
"""``<Slot name="footer" :item="row">fallback</Slot>`` in a component's
|
|
86
|
+
template: where the content its parent passed is rendered. ``attrs`` and
|
|
87
|
+
``expr_attrs`` are slot props, handed to that content as variables."""
|
|
88
|
+
|
|
89
|
+
name: str = "default"
|
|
90
|
+
attrs: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
|
91
|
+
expr_attrs: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
children: list[Any] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class TFill:
|
|
97
|
+
"""``<Fill name="footer">...</Fill>`` directly inside a component tag:
|
|
98
|
+
content for one of its named slots."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
children: list[Any] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------ source slicing
|
|
105
|
+
|
|
106
|
+
_BLOCK_OPEN = re.compile(r"<(template|code|style)\s*>")
|
|
107
|
+
_TEMPLATE_TAG = re.compile(r"<(/?)template\b[^>]*?(/?)>")
|
|
108
|
+
_AFTER_BLOCK = re.compile(r"\s*(?:$|<(?:template|code|style)\s*>|<!--)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _template_end(src: str, start: int) -> re.Match[str] | None:
|
|
112
|
+
# Balanced, so a <template> element inside the markup can't end it.
|
|
113
|
+
depth = 1
|
|
114
|
+
for tag in _TEMPLATE_TAG.finditer(src, start):
|
|
115
|
+
if tag.group(1):
|
|
116
|
+
depth -= 1
|
|
117
|
+
if depth == 0:
|
|
118
|
+
return tag
|
|
119
|
+
elif not tag.group(2):
|
|
120
|
+
depth += 1
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _raw_end(src: str, start: int, name: str) -> re.Match[str] | None:
|
|
125
|
+
# Python and CSS aren't markup, so nothing can be balanced: the block
|
|
126
|
+
# ends at the first closing tag followed by another block or the end,
|
|
127
|
+
# so "</code>" inside a string mostly stays in. Failing that, the last.
|
|
128
|
+
closes = list(re.finditer(rf"</{name}\s*>", src[start:]))
|
|
129
|
+
for close in closes:
|
|
130
|
+
if _AFTER_BLOCK.match(src, start + close.end()):
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
if not closes:
|
|
134
|
+
return None
|
|
135
|
+
close = closes[-1]
|
|
136
|
+
return re.compile(rf"</{name}\s*>").match(src, start + close.start())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def block_spans(src: str) -> dict[str, tuple[int, int]]:
|
|
140
|
+
"""Where the top-level ``<template>``, ``<code>`` and ``<style>`` blocks'
|
|
141
|
+
contents are: ``{"code": (start, end), ...}``. Blocks are found by
|
|
142
|
+
scanning, not by regex over the whole file, so markup inside the
|
|
143
|
+
template (an HTML ``<code>`` element, say) is not taken for a block."""
|
|
144
|
+
spans: dict[str, tuple[int, int]] = {}
|
|
145
|
+
pos = 0
|
|
146
|
+
while (opening := _BLOCK_OPEN.search(src, pos)) is not None:
|
|
147
|
+
comment = src.find("<!--", pos, opening.start())
|
|
148
|
+
if comment != -1:
|
|
149
|
+
close = src.find("-->", comment + 4)
|
|
150
|
+
pos = len(src) if close == -1 else close + 3
|
|
151
|
+
continue
|
|
152
|
+
name = opening.group(1)
|
|
153
|
+
end = (
|
|
154
|
+
_template_end(src, opening.end())
|
|
155
|
+
if name == "template"
|
|
156
|
+
else _raw_end(src, opening.end(), name)
|
|
157
|
+
)
|
|
158
|
+
if end is None:
|
|
159
|
+
raise TemplateError(f"<{name}> block is not closed")
|
|
160
|
+
spans.setdefault(name, (opening.end(), end.start()))
|
|
161
|
+
pos = end.end()
|
|
162
|
+
return spans
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def split_source(src: str) -> tuple[str, str, str]:
|
|
166
|
+
"""Return (template_source, code_source, style_source)."""
|
|
167
|
+
spans = block_spans(src)
|
|
168
|
+
if "template" not in spans:
|
|
169
|
+
raise TemplateError("no <template> block found")
|
|
170
|
+
|
|
171
|
+
def content(name: str) -> str:
|
|
172
|
+
start, end = spans.get(name, (0, 0))
|
|
173
|
+
return src[start:end]
|
|
174
|
+
|
|
175
|
+
return content("template"), textwrap.dedent(content("code")), textwrap.dedent(content("style"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_AT_ATTR = re.compile(r"@([A-Za-z_][\w.\-]*)\s*=")
|
|
179
|
+
_COLON_ATTR = re.compile(r":([A-Za-z_][\w.\-]*)\s*=")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rewrite_at_directives(src: str) -> str:
|
|
183
|
+
"""Rewrite ``@click="..."`` to ``py-at-click="..."`` and ``:prop="..."``
|
|
184
|
+
to ``py-colon-prop="..."``.
|
|
185
|
+
|
|
186
|
+
XML attribute names cannot start with ``@`` or ``:``, so the Blazor-ish /
|
|
187
|
+
Vue-ish sugar has to be normalised before the XML parser sees it. This is a
|
|
188
|
+
small state machine rather than a bare regex so that ``@`` or ``:`` inside
|
|
189
|
+
text or attribute values (an email address, a URL) is left alone.
|
|
190
|
+
"""
|
|
191
|
+
out: list[str] = []
|
|
192
|
+
i, n = 0, len(src)
|
|
193
|
+
in_tag = False
|
|
194
|
+
quote: str | None = None
|
|
195
|
+
while i < n:
|
|
196
|
+
ch = src[i]
|
|
197
|
+
if not in_tag:
|
|
198
|
+
if ch == "<":
|
|
199
|
+
in_tag = True
|
|
200
|
+
out.append(ch)
|
|
201
|
+
i += 1
|
|
202
|
+
continue
|
|
203
|
+
if quote:
|
|
204
|
+
if ch == quote:
|
|
205
|
+
quote = None
|
|
206
|
+
out.append(ch)
|
|
207
|
+
i += 1
|
|
208
|
+
continue
|
|
209
|
+
if ch in "\"'":
|
|
210
|
+
quote = ch
|
|
211
|
+
out.append(ch)
|
|
212
|
+
i += 1
|
|
213
|
+
continue
|
|
214
|
+
if ch == ">":
|
|
215
|
+
in_tag = False
|
|
216
|
+
out.append(ch)
|
|
217
|
+
i += 1
|
|
218
|
+
continue
|
|
219
|
+
if ch == "@" and out and (out[-1].isspace() or out[-1] == "<"):
|
|
220
|
+
match = _AT_ATTR.match(src, i)
|
|
221
|
+
if match:
|
|
222
|
+
out.append("py-at-" + match.group(1) + "=")
|
|
223
|
+
i = match.end()
|
|
224
|
+
continue
|
|
225
|
+
# Only after whitespace: an attribute-name position. A ':' elsewhere in
|
|
226
|
+
# a tag (a namespaced attribute name, say) is not our sugar.
|
|
227
|
+
if ch == ":" and out and out[-1].isspace():
|
|
228
|
+
match = _COLON_ATTR.match(src, i)
|
|
229
|
+
if match:
|
|
230
|
+
out.append("py-colon-" + match.group(1) + "=")
|
|
231
|
+
i = match.end()
|
|
232
|
+
continue
|
|
233
|
+
out.append(ch)
|
|
234
|
+
i += 1
|
|
235
|
+
return "".join(out)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------- conversion
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_template(template_src: str) -> list[Any]:
|
|
242
|
+
src = rewrite_at_directives(template_src)
|
|
243
|
+
try:
|
|
244
|
+
root = ET.fromstring("<pyweft-root>" + src + "</pyweft-root>")
|
|
245
|
+
except ET.ParseError as exc:
|
|
246
|
+
raise TemplateError(f"template is not well-formed XML: {exc}") from exc
|
|
247
|
+
return _convert_children(root)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _text_nodes(text: str | None) -> list[Any]:
|
|
251
|
+
"""Whitespace-only text containing a newline is formatting; drop it."""
|
|
252
|
+
if not text:
|
|
253
|
+
return []
|
|
254
|
+
if not text.strip():
|
|
255
|
+
return [] if "\n" in text else [TStatic([("static", text)])]
|
|
256
|
+
return [TStatic(parse_interpolation(text))]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_EACH_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s+in\s+(.+)$", re.S)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _convert_children(el: ET.Element) -> list[Any]:
|
|
263
|
+
out: list[Any] = []
|
|
264
|
+
out += _text_nodes(el.text)
|
|
265
|
+
kids = [k for k in el if not callable(k.tag)] # drop comments / PIs
|
|
266
|
+
i = 0
|
|
267
|
+
while i < len(kids):
|
|
268
|
+
child = kids[i]
|
|
269
|
+
if child.tag in ("ElseIf", "Else"):
|
|
270
|
+
raise TemplateError(f"<{child.tag}> must directly follow an <If>")
|
|
271
|
+
if child.tag == "If":
|
|
272
|
+
node, i = _convert_if(kids, i)
|
|
273
|
+
out.append(node)
|
|
274
|
+
out += _text_nodes(kids[i - 1].tail)
|
|
275
|
+
continue
|
|
276
|
+
if child.tag == "For":
|
|
277
|
+
out.append(_convert_for(child))
|
|
278
|
+
out += _text_nodes(child.tail)
|
|
279
|
+
i += 1
|
|
280
|
+
continue
|
|
281
|
+
if child.tag in ("Slot", "Fill"):
|
|
282
|
+
out.append(_convert_slot(child) if child.tag == "Slot" else _convert_fill(child))
|
|
283
|
+
out += _text_nodes(child.tail)
|
|
284
|
+
i += 1
|
|
285
|
+
continue
|
|
286
|
+
out.append(_convert_element(child))
|
|
287
|
+
out += _text_nodes(child.tail)
|
|
288
|
+
i += 1
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _convert_if(kids: list[ET.Element], i: int) -> tuple[TIf, int]:
|
|
293
|
+
node = kids[i]
|
|
294
|
+
cond = node.get("cond")
|
|
295
|
+
if cond is None:
|
|
296
|
+
raise TemplateError('<If> requires a cond attribute, e.g. cond="count > 5"')
|
|
297
|
+
branches: list[tuple[str | None, list[Any]]] = [(cond, _convert_children(node))]
|
|
298
|
+
j = i + 1
|
|
299
|
+
while j < len(kids) and kids[j].tag in ("ElseIf", "Else"):
|
|
300
|
+
if (kids[j - 1].tail or "").strip():
|
|
301
|
+
break # real content between the branches; stop folding
|
|
302
|
+
nxt = kids[j]
|
|
303
|
+
if nxt.tag == "ElseIf":
|
|
304
|
+
cond2 = nxt.get("cond")
|
|
305
|
+
if cond2 is None:
|
|
306
|
+
raise TemplateError("<ElseIf> requires a cond attribute")
|
|
307
|
+
branches.append((cond2, _convert_children(nxt)))
|
|
308
|
+
j += 1
|
|
309
|
+
else:
|
|
310
|
+
branches.append((None, _convert_children(nxt)))
|
|
311
|
+
j += 1
|
|
312
|
+
break
|
|
313
|
+
return TIf(branches), j
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _convert_for(el: ET.Element) -> TFor:
|
|
317
|
+
each = el.get("each")
|
|
318
|
+
if not each:
|
|
319
|
+
raise TemplateError('<For> requires each="item in items"')
|
|
320
|
+
match = _EACH_RE.match(each)
|
|
321
|
+
if not match:
|
|
322
|
+
raise TemplateError(f'bad each expression {each!r}, expected "item in items"')
|
|
323
|
+
return TFor(
|
|
324
|
+
var=match.group(1),
|
|
325
|
+
iter_src=match.group(2).strip(),
|
|
326
|
+
index_var=el.get("index"),
|
|
327
|
+
key_expr=el.get("key"),
|
|
328
|
+
children=_convert_children(el),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_SLOT_NAME = re.compile(r"^[A-Za-z_][\w-]*$")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _slot_name(el: ET.Element, required: bool) -> str:
|
|
336
|
+
name = el.get("name")
|
|
337
|
+
if name is None:
|
|
338
|
+
if required:
|
|
339
|
+
raise TemplateError('<Fill> requires name="...", the slot it fills')
|
|
340
|
+
return "default"
|
|
341
|
+
if not _SLOT_NAME.match(name):
|
|
342
|
+
raise TemplateError(f"<{el.tag} name={name!r}>: not a valid slot name")
|
|
343
|
+
return name
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _convert_slot(el: ET.Element) -> TSlot:
|
|
347
|
+
attrs: dict[str, list[tuple[str, str]]] = {}
|
|
348
|
+
expr_attrs: dict[str, str] = {}
|
|
349
|
+
for name, value in el.attrib.items():
|
|
350
|
+
if name == "name":
|
|
351
|
+
continue
|
|
352
|
+
if name.startswith("py-at-"):
|
|
353
|
+
raise TemplateError(f"<Slot> takes no events (@{name[6:]})")
|
|
354
|
+
if name.startswith("py-colon-"):
|
|
355
|
+
name, target = name[len("py-colon-") :], expr_attrs
|
|
356
|
+
else:
|
|
357
|
+
target = attrs
|
|
358
|
+
if not name.isidentifier():
|
|
359
|
+
raise TemplateError(
|
|
360
|
+
f"<Slot> prop {name!r} must be a Python name: the content that "
|
|
361
|
+
"fills the slot sees it as a variable"
|
|
362
|
+
)
|
|
363
|
+
if target is expr_attrs:
|
|
364
|
+
expr_attrs[name] = value
|
|
365
|
+
else:
|
|
366
|
+
attrs[name] = parse_interpolation(value)
|
|
367
|
+
return TSlot(
|
|
368
|
+
name=_slot_name(el, required=False),
|
|
369
|
+
attrs=attrs,
|
|
370
|
+
expr_attrs=expr_attrs,
|
|
371
|
+
children=_convert_children(el),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _convert_fill(el: ET.Element) -> TFill:
|
|
376
|
+
extra = sorted(set(el.attrib) - {"name"})
|
|
377
|
+
if extra:
|
|
378
|
+
raise TemplateError(f"<Fill> takes only name=, not {', '.join(extra)}")
|
|
379
|
+
return TFill(name=_slot_name(el, required=True), children=_convert_children(el))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _convert_element(el: ET.Element) -> TElement:
|
|
383
|
+
attrs: dict[str, list[tuple[str, str]]] = {}
|
|
384
|
+
events: dict[str, str] = {}
|
|
385
|
+
expr_attrs: dict[str, str] = {}
|
|
386
|
+
bind: str | None = None
|
|
387
|
+
bind_mods: tuple[str, ...] = ()
|
|
388
|
+
key_expr: str | None = None
|
|
389
|
+
|
|
390
|
+
for name, value in el.attrib.items():
|
|
391
|
+
if name == "key":
|
|
392
|
+
key_expr = value
|
|
393
|
+
elif name.startswith("py-colon-"):
|
|
394
|
+
expr_attrs[name[len("py-colon-") :]] = value
|
|
395
|
+
elif name.startswith("py-at-"):
|
|
396
|
+
directive = name[len("py-at-") :]
|
|
397
|
+
try:
|
|
398
|
+
if directive == "bind" or directive.startswith("bind."):
|
|
399
|
+
if not value.isidentifier():
|
|
400
|
+
raise TemplateError(
|
|
401
|
+
f"@bind takes a plain state name, got {value!r}"
|
|
402
|
+
)
|
|
403
|
+
bind = value
|
|
404
|
+
bind_mods = parse_bind_modifiers(directive)
|
|
405
|
+
else:
|
|
406
|
+
events[parse_event_spec(directive)] = value
|
|
407
|
+
except ValueError as exc:
|
|
408
|
+
raise TemplateError(f"<{el.tag}> {exc}") from None
|
|
409
|
+
else:
|
|
410
|
+
attrs[name] = parse_interpolation(value)
|
|
411
|
+
|
|
412
|
+
if el.tag == "Dynamic" and "is" not in expr_attrs:
|
|
413
|
+
raise TemplateError(
|
|
414
|
+
'<Dynamic> needs :is="..." naming the component class to render'
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return TElement(
|
|
418
|
+
tag=el.tag,
|
|
419
|
+
attrs=attrs,
|
|
420
|
+
events=events,
|
|
421
|
+
bind=bind,
|
|
422
|
+
bind_mods=bind_mods,
|
|
423
|
+
key_expr=key_expr,
|
|
424
|
+
children=_convert_children(el),
|
|
425
|
+
expr_attrs=expr_attrs,
|
|
426
|
+
)
|
pyweft/prerender.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Render an app's first screen ahead of time, on the developer's machine.
|
|
2
|
+
|
|
3
|
+
``pyweft run`` and ``pyweft build`` put the result in the page, so visitors
|
|
4
|
+
see the real app while Python starts in the browser instead of a loading
|
|
5
|
+
screen; the browser's first render then takes over the same markup. The CLI
|
|
6
|
+
runs this as ``python -m pyweft.prerender APP_DIR`` in a separate process:
|
|
7
|
+
the app's code runs here too (up to the first ``await`` of an async
|
|
8
|
+
``on_init``), and must not be able to break or slow down the CLI itself.
|
|
9
|
+
|
|
10
|
+
Host-only, like cli.py: never shipped to the browser.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import html
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import routing
|
|
23
|
+
from .component import load_component
|
|
24
|
+
from .hotreload import format_error
|
|
25
|
+
from .renderer import Renderer
|
|
26
|
+
from .styles import collect_styles
|
|
27
|
+
from .vdom import Element, Node
|
|
28
|
+
|
|
29
|
+
MARK = "PYWEFT-PRERENDER " # prefixes the result line, so the app may print
|
|
30
|
+
|
|
31
|
+
# Elements with no closing tag in HTML.
|
|
32
|
+
_VOID = frozenset(
|
|
33
|
+
"area base br col embed hr img input link meta source track wbr".split()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def to_html(nodes: list[Node]) -> str:
|
|
38
|
+
"""The vdom as HTML, as the runtime would build it: same elements, same
|
|
39
|
+
attributes. Events don't exist until Python runs, and scripts are left
|
|
40
|
+
out, since the runtime never runs them either."""
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
_write(nodes, out, selected=None)
|
|
43
|
+
return "".join(out)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write(nodes: list[Node], out: list[str], selected: str | None) -> None:
|
|
47
|
+
for node in nodes:
|
|
48
|
+
if not isinstance(node, Element):
|
|
49
|
+
out.append(html.escape(node.text, quote=False))
|
|
50
|
+
continue
|
|
51
|
+
if node.tag == "script":
|
|
52
|
+
continue
|
|
53
|
+
attrs = dict(node.attrs)
|
|
54
|
+
inner = selected
|
|
55
|
+
# value is a live property for these, not markup: say it in HTML.
|
|
56
|
+
if node.tag == "textarea":
|
|
57
|
+
text = attrs.pop("value", "")
|
|
58
|
+
elif node.tag == "select":
|
|
59
|
+
inner = attrs.pop("value", None)
|
|
60
|
+
elif node.tag == "option" and selected is not None and attrs.get("value") == selected:
|
|
61
|
+
attrs["selected"] = ""
|
|
62
|
+
out.append(f"<{node.tag}")
|
|
63
|
+
for name, value in attrs.items():
|
|
64
|
+
out.append(f' {name}="{html.escape(value)}"' if value else f" {name}")
|
|
65
|
+
out.append(">")
|
|
66
|
+
if node.tag in _VOID:
|
|
67
|
+
continue
|
|
68
|
+
if node.tag == "textarea":
|
|
69
|
+
out.append(html.escape(text, quote=False))
|
|
70
|
+
else:
|
|
71
|
+
_write(node.children, out, inner)
|
|
72
|
+
out.append(f"</{node.tag}>")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prerender(app_dir: Path) -> dict[str, Any]:
|
|
76
|
+
"""``{"html", "css", "location"}`` for the app's first screen.
|
|
77
|
+
``location`` is the address it was rendered at, or ``""`` when the app
|
|
78
|
+
doesn't route, so any address may show it."""
|
|
79
|
+
app_dir = app_dir.resolve()
|
|
80
|
+
sys.path.insert(0, str(app_dir)) # the app's own modules, as in the browser
|
|
81
|
+
routing.set_host(routing.MemoryHistory("/"))
|
|
82
|
+
App = load_component(app_dir / "app.weft")
|
|
83
|
+
|
|
84
|
+
async def first_screen() -> list[Node]:
|
|
85
|
+
# What the browser shows once its first render has settled: effects
|
|
86
|
+
# and the synchronous start of an async on_init included.
|
|
87
|
+
renderer = Renderer(App())
|
|
88
|
+
renderer.init_root()
|
|
89
|
+
tree = renderer.render()
|
|
90
|
+
for _ in range(100):
|
|
91
|
+
if not renderer.after_render():
|
|
92
|
+
break
|
|
93
|
+
tree = renderer.render()
|
|
94
|
+
for task in renderer.pending_tasks():
|
|
95
|
+
task.cancel()
|
|
96
|
+
return tree
|
|
97
|
+
|
|
98
|
+
tree = asyncio.run(first_screen())
|
|
99
|
+
css = collect_styles(App)
|
|
100
|
+
return {
|
|
101
|
+
"html": to_html(tree),
|
|
102
|
+
# Inside <style>, only "</style" could end the element early.
|
|
103
|
+
"css": css.replace("</style", "<\\/style"),
|
|
104
|
+
"location": routing._current() if routing._url is not None else "",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
args = sys.argv[1:] if argv is None else argv
|
|
110
|
+
app_dir = Path(args[0])
|
|
111
|
+
try:
|
|
112
|
+
result = prerender(app_dir)
|
|
113
|
+
except BaseException as exc: # noqa: BLE001 - reported, never raised
|
|
114
|
+
result = {"error": format_error(exc, root=str(app_dir.resolve()))}
|
|
115
|
+
sys.__stdout__.write(MARK + json.dumps(result) + "\n")
|
|
116
|
+
sys.__stdout__.flush()
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|