workpeg 0.97.2__tar.gz → 0.98.0__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.
- {workpeg-0.97.2/src/workpeg.egg-info → workpeg-0.98.0}/PKG-INFO +1 -1
- {workpeg-0.97.2 → workpeg-0.98.0}/pyproject.toml +1 -1
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/build.py +135 -9
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/ast_parser.py +184 -33
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/js_generator.py +3 -1
- workpeg-0.98.0/src/workpeg/ui/runtime/icons.css.gz +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0/src/workpeg.egg-info}/PKG-INFO +1 -1
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/SOURCES.txt +1 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/LICENSE +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/MANIFEST.in +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/README.md +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/setup.cfg +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/build.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/cdn.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/cli.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/config.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/context.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/create_new.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/run.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/runtime.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/submit.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/Dockerfile +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/LICENSE +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/README.md +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/app/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/app/main.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/requirements.txt +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/actions.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/app.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/ir.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/create.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/fake_cloud.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/minify.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/ai.js +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/pegment.css +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/scaffold.js +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/workpeg-runtime.js +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/scaffold.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/serve.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/state.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/validators.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/watcher.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/__init__.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/base.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/canvas.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/display.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/forms.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/futures.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/gestures.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/interactive.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/layout.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/utils.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/entry_points.txt +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/requires.txt +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/top_level.txt +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_build.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cdn.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cdn_publish.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_changelog.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_changelog_guard.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cli.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_config.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_context.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_create_new.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_release_script.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_run.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_runtime.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_submit.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_utils.py +0 -0
- {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_watch_pipeline.py +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import gzip
|
|
3
4
|
import hashlib
|
|
4
5
|
import html
|
|
6
|
+
import re
|
|
5
7
|
import shutil
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from urllib.parse import urlparse
|
|
@@ -85,6 +87,112 @@ def _icons_version(bundled: bool) -> str:
|
|
|
85
87
|
return "0"
|
|
86
88
|
|
|
87
89
|
|
|
90
|
+
#: A gzip of the icon source, packaged INTO the wheel (~1 MB vs the 26 MB raw,
|
|
91
|
+
#: which stays excluded). It lets a wheel-only production build subset icons.css
|
|
92
|
+
#: without carrying the full sheet on disk. Dev/editable installs read the raw
|
|
93
|
+
#: file directly; either way the build ships only the tiny per-app subset.
|
|
94
|
+
_ICONS_CSS_GZ = _ICONS_CSS.parent / "icons.css.gz"
|
|
95
|
+
|
|
96
|
+
#: (source identity) -> (base rules, {icon-name: its rules}), so a watch rebuild
|
|
97
|
+
#: parses the ~26 MB sheet once, not per save. And the runtime's own icon set.
|
|
98
|
+
_icon_rules_memo: "dict[tuple, tuple]" = {}
|
|
99
|
+
_runtime_base_memo: "set[str] | None" = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _icons_source_key() -> "tuple | None":
|
|
103
|
+
"""A cheap identity for the icon source (stat only, no read) — the raw
|
|
104
|
+
checkout copy preferred, else the packaged gzip; None when neither exists."""
|
|
105
|
+
if _ICONS_CSS.is_file():
|
|
106
|
+
st = _ICONS_CSS.stat()
|
|
107
|
+
return ("raw", st.st_mtime_ns, st.st_size)
|
|
108
|
+
if _ICONS_CSS_GZ.is_file():
|
|
109
|
+
st = _ICONS_CSS_GZ.stat()
|
|
110
|
+
return ("gz", st.st_mtime_ns, st.st_size)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_icons_source() -> str:
|
|
115
|
+
"""The full icon stylesheet text — the raw file, or the decompressed gzip."""
|
|
116
|
+
if _ICONS_CSS.is_file():
|
|
117
|
+
return _ICONS_CSS.read_text(encoding="utf-8")
|
|
118
|
+
return gzip.decompress(_ICONS_CSS_GZ.read_bytes()).decode("utf-8")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _icon_rule_index() -> "tuple | None":
|
|
122
|
+
"""Parse the icon source into ``(base_rules, {name: its_rules})`` once,
|
|
123
|
+
memoized by source identity. Each ``.ti-NAME`` rule (and its ``.ti-thin`` /
|
|
124
|
+
``.ti-bolder`` weight variants) is filed under NAME; rules with no
|
|
125
|
+
icon-specific class (the base ``.ti``) are base. ``{`` / ``}`` appear only as
|
|
126
|
+
rule delimiters — the SVG data-URIs are percent-encoded — so a flat regex
|
|
127
|
+
split is safe. Returns None when no icon source is present."""
|
|
128
|
+
key = _icons_source_key()
|
|
129
|
+
if key is None:
|
|
130
|
+
return None
|
|
131
|
+
if key in _icon_rules_memo:
|
|
132
|
+
return _icon_rules_memo[key]
|
|
133
|
+
text = _read_icons_source()
|
|
134
|
+
base: "list[str]" = []
|
|
135
|
+
per: "dict[str, list[str]]" = {}
|
|
136
|
+
for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", text):
|
|
137
|
+
rule = m.group(0)
|
|
138
|
+
names = [n for n in re.findall(r"\.ti-([a-z0-9-]+)", m.group(1))
|
|
139
|
+
if n not in ("thin", "bolder")]
|
|
140
|
+
if not names:
|
|
141
|
+
base.append(rule)
|
|
142
|
+
else:
|
|
143
|
+
for n in set(names):
|
|
144
|
+
per.setdefault(n, []).append(rule)
|
|
145
|
+
index = ("".join(base), {n: "".join(r) for n, r in per.items()})
|
|
146
|
+
_icon_rules_memo.clear() # one live entry is plenty
|
|
147
|
+
_icon_rules_memo[key] = index
|
|
148
|
+
return index
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _runtime_base_icons() -> "set[str]":
|
|
152
|
+
"""Icons the runtime bundles render themselves (the Scaffold chrome, the AI
|
|
153
|
+
surface, Icon fallbacks) — scanned from the runtime JS so the base set stays
|
|
154
|
+
in sync automatically, and always kept in a subset so the chrome renders.
|
|
155
|
+
Over-matching is harmless: only names that exist in the icon source are
|
|
156
|
+
emitted when subsetting."""
|
|
157
|
+
global _runtime_base_memo
|
|
158
|
+
if _runtime_base_memo is not None:
|
|
159
|
+
return _runtime_base_memo
|
|
160
|
+
names: "set[str]" = set()
|
|
161
|
+
for p in (_RUNTIME_JS, _SCAFFOLD_JS, _AI_JS):
|
|
162
|
+
if not p.is_file():
|
|
163
|
+
continue
|
|
164
|
+
txt = p.read_text(encoding="utf-8")
|
|
165
|
+
# The chrome names icons in many forms — icon("x"), icon: "x",
|
|
166
|
+
# topAction("edit"), iconBtn("refresh"), ti-history — so collect EVERY
|
|
167
|
+
# icon-shaped quoted string (and the ti-<name> classes). Over-matching
|
|
168
|
+
# is harmless: only names present in the icon source are emitted when
|
|
169
|
+
# subsetting, so non-icon strings ("click", "flex", …) drop out.
|
|
170
|
+
names.update(re.findall(r"""["']([a-z][a-z0-9-]*)["']""", txt))
|
|
171
|
+
names.update(re.findall(r"\bti-([a-z0-9-]+)\b", txt))
|
|
172
|
+
names -= {"thin", "bolder", "filled"}
|
|
173
|
+
# Selected/active states concatenate a "-filled" suffix (ti- + base +
|
|
174
|
+
# "-filled"), e.g. the thumb-up/down feedback — include those variants too.
|
|
175
|
+
names |= {n + "-filled" for n in tuple(names)}
|
|
176
|
+
_runtime_base_memo = names
|
|
177
|
+
return names
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _subset_icons_css(keep: "set[str]") -> "str | None":
|
|
181
|
+
"""The icon stylesheet trimmed to ``keep`` plus the runtime's own icons —
|
|
182
|
+
the base ``.ti`` rules followed by only the used icons' rules. None when no
|
|
183
|
+
icon source is available (the caller then links the CDN full set)."""
|
|
184
|
+
index = _icon_rule_index()
|
|
185
|
+
if index is None:
|
|
186
|
+
return None
|
|
187
|
+
base, per = index
|
|
188
|
+
parts = [base]
|
|
189
|
+
for name in sorted(keep | _runtime_base_icons()):
|
|
190
|
+
rules = per.get(name)
|
|
191
|
+
if rules:
|
|
192
|
+
parts.append(rules)
|
|
193
|
+
return "".join(parts)
|
|
194
|
+
|
|
195
|
+
|
|
88
196
|
def build_ui(
|
|
89
197
|
source_path: str,
|
|
90
198
|
output_dir: str = "dist",
|
|
@@ -106,7 +214,8 @@ def build_ui(
|
|
|
106
214
|
if public.is_dir():
|
|
107
215
|
shutil.copytree(public, out, dirs_exist_ok=True)
|
|
108
216
|
|
|
109
|
-
|
|
217
|
+
parser = ASTParser()
|
|
218
|
+
ir = parser.parse_file(str(source))
|
|
110
219
|
gen = JSGenerator()
|
|
111
220
|
js = gen.generate(ir)
|
|
112
221
|
runtime = _RUNTIME_JS.read_text(encoding="utf-8")
|
|
@@ -156,25 +265,42 @@ def build_ui(
|
|
|
156
265
|
(out / "ai.js").write_text(ai_js, encoding="utf-8")
|
|
157
266
|
if scaffold_js:
|
|
158
267
|
(out / "scaffold.js").write_text(scaffold_js, encoding="utf-8")
|
|
268
|
+
# Icon stylesheet. When the app's icons are all static, subset the sheet to
|
|
269
|
+
# just those (+ the runtime's own icons) and ship the tiny result — a page
|
|
270
|
+
# then loads a few KB instead of the ~26 MB full set. Works from the raw
|
|
271
|
+
# file (dev) or the packaged gzip (wheel). Dynamic icon names, or no icon
|
|
272
|
+
# source at all, fall back to the CDN full set (a dev build with the raw
|
|
273
|
+
# file still bundles it locally so it works offline).
|
|
159
274
|
icons_href = ICONS_CDN
|
|
160
|
-
|
|
161
|
-
if
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
275
|
+
icons_ver = _icons_version(False) if gen.uses_icons else ""
|
|
276
|
+
if gen.uses_icons and not parser.dynamic_icons:
|
|
277
|
+
subset = _subset_icons_css(parser.icon_names)
|
|
278
|
+
if subset is not None:
|
|
279
|
+
(out / "icons.css").write_text(subset, encoding="utf-8")
|
|
280
|
+
icons_href = ICONS_HREF
|
|
281
|
+
icons_ver = hashlib.sha1(subset.encode("utf-8")).hexdigest()[:8]
|
|
282
|
+
elif bundle_icons and gen.uses_icons and _ICONS_CSS.is_file():
|
|
283
|
+
# Dynamic icon names in a dev build: can't subset, so bundle the full
|
|
284
|
+
# local sheet (refreshed only when newer, so a watch rebuild doesn't
|
|
285
|
+
# rewrite ~26 MB per save). Production links the CDN copy above.
|
|
167
286
|
icons_href = ICONS_HREF
|
|
168
287
|
target = out / "icons.css"
|
|
288
|
+
# Size-aware, not mtime-only: a prior build may have left a KB-sized
|
|
289
|
+
# SUBSET here (static → then edited to a dynamic icon), whose mtime is
|
|
290
|
+
# newer than the source — a size mismatch forces the full sheet back in.
|
|
291
|
+
# The full → full fast path (equal size, copy2-preserved mtime) still
|
|
292
|
+
# skips the 26 MB rewrite on a watch save.
|
|
169
293
|
if (not target.exists()
|
|
294
|
+
or target.stat().st_size != _ICONS_CSS.stat().st_size
|
|
170
295
|
or target.stat().st_mtime < _ICONS_CSS.stat().st_mtime):
|
|
171
296
|
shutil.copy2(_ICONS_CSS, target)
|
|
297
|
+
icons_ver = _icons_version(True)
|
|
172
298
|
(out / "index.html").write_text(
|
|
173
299
|
_gen_html(
|
|
174
300
|
ir.name, ns, ver,
|
|
175
301
|
uses_icons=gen.uses_icons,
|
|
176
302
|
icons_href=icons_href,
|
|
177
|
-
icons_ver=
|
|
303
|
+
icons_ver=icons_ver,
|
|
178
304
|
fonts=getattr(ir, "fonts", None),
|
|
179
305
|
),
|
|
180
306
|
encoding="utf-8",
|
|
@@ -84,6 +84,12 @@ _WORKPEG_SESSION_HELPERS = {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _is_str_const(node: "ast.AST | None") -> bool:
|
|
88
|
+
"""True when *node* is a string literal (an ``ast.Constant`` holding a str)
|
|
89
|
+
— used by the icon-literal scan to pick out ``icon="…"`` values."""
|
|
90
|
+
return isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
91
|
+
|
|
92
|
+
|
|
87
93
|
def _is_workpeg_host(url: str) -> bool:
|
|
88
94
|
"""True when *url* targets a Workpeg-owned host — ``workpeg`` as the
|
|
89
95
|
REGISTRABLE label, the one right before the TLD (workpeg.com, *.workpeg.io,
|
|
@@ -447,6 +453,11 @@ class ASTParser:
|
|
|
447
453
|
_current_props: frozenset = frozenset()
|
|
448
454
|
_current_methods: frozenset = frozenset()
|
|
449
455
|
_root_base_dir: "str | None" = None
|
|
456
|
+
#: Comprehension loop vars in scope that iterate a STATICALLY known set — a
|
|
457
|
+
#: list/tuple literal, or a component prop fed literals at call sites. An
|
|
458
|
+
#: icon named through one is collectable (not runtime-dynamic); a loop var
|
|
459
|
+
#: over State / a call is NOT here, so the build keeps the full sheet.
|
|
460
|
+
_safe_loop_vars: frozenset = frozenset()
|
|
450
461
|
|
|
451
462
|
def __init__(self) -> None:
|
|
452
463
|
self._components: dict[str, "ComponentIR | None"] = {}
|
|
@@ -483,6 +494,15 @@ class ASTParser:
|
|
|
483
494
|
# instead of "<unknown>".
|
|
484
495
|
tree = ast.parse(source, filename=filename or "<unknown>")
|
|
485
496
|
|
|
497
|
+
# Icon subsetting: the set of literal icon names this build renders (so
|
|
498
|
+
# build.py can trim icons.css to just those), and whether any icon name
|
|
499
|
+
# is state-derived (then the full sheet is kept). Reset per parse and
|
|
500
|
+
# accumulated across the entry file + every imported/page module below.
|
|
501
|
+
self.icon_names = set()
|
|
502
|
+
self.dynamic_icons = False
|
|
503
|
+
self._safe_loop_vars = frozenset()
|
|
504
|
+
self._scan_icon_literals(tree)
|
|
505
|
+
|
|
486
506
|
# Collect every class definition so Route entries can cross-reference them.
|
|
487
507
|
class_defs: dict[str, ast.ClassDef] = {}
|
|
488
508
|
for node in ast.walk(tree):
|
|
@@ -995,6 +1015,7 @@ class ASTParser:
|
|
|
995
1015
|
) -> "tuple[dict[str, ast.ClassDef], dict[str, str], dict[str, Any]]":
|
|
996
1016
|
with open(file_path) as f:
|
|
997
1017
|
tree = ast.parse(f.read(), filename=_display_path(file_path))
|
|
1018
|
+
self._scan_icon_literals(tree) # icon literals in imported modules
|
|
998
1019
|
cdefs = {
|
|
999
1020
|
n.name: n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)
|
|
1000
1021
|
}
|
|
@@ -1013,6 +1034,7 @@ class ASTParser:
|
|
|
1013
1034
|
extra: list[Any] = [] # positional args beyond the named props
|
|
1014
1035
|
for i, a in enumerate(node.args):
|
|
1015
1036
|
if i < len(comp.props):
|
|
1037
|
+
self._note_component_icon_prop(comp.props[i], a, scope)
|
|
1016
1038
|
args[comp.props[i]] = self._parse_prop_value(a, scope)
|
|
1017
1039
|
else:
|
|
1018
1040
|
extra.append(self._parse_prop_value(a, scope))
|
|
@@ -1039,6 +1061,7 @@ class ASTParser:
|
|
|
1039
1061
|
raise UnsupportedSyntaxError(
|
|
1040
1062
|
f"{comp.name}() has no prop '{kw.arg}'"
|
|
1041
1063
|
)
|
|
1064
|
+
self._note_component_icon_prop(kw.arg, kw.value, scope)
|
|
1042
1065
|
args[kw.arg] = self._parse_prop_value(kw.value, scope)
|
|
1043
1066
|
# Fill omitted parameters from their literal __init__ defaults, the
|
|
1044
1067
|
# way Python would — so NavItem(label, href) really gets icon="circle".
|
|
@@ -2211,7 +2234,7 @@ class ASTParser:
|
|
|
2211
2234
|
raise UnsupportedSyntaxError(
|
|
2212
2235
|
"a canvas comprehension supports a single 'for' clause"
|
|
2213
2236
|
)
|
|
2214
|
-
params, inner, iterable = self._parse_comp_target(gens[0], scope)
|
|
2237
|
+
params, inner, iterable, _ = self._parse_comp_target(gens[0], scope)
|
|
2215
2238
|
cond = self._and_conditions(gens[0].ifs, inner) if gens[0].ifs else None
|
|
2216
2239
|
elt = self._parse_canvas_op(comp.elt, inner)
|
|
2217
2240
|
return WidgetIR(type="__each", props={
|
|
@@ -2684,6 +2707,7 @@ class ASTParser:
|
|
|
2684
2707
|
source = f.read()
|
|
2685
2708
|
try:
|
|
2686
2709
|
tree = ast.parse(source, filename=display)
|
|
2710
|
+
self._scan_icon_literals(tree) # icon literals in the page module
|
|
2687
2711
|
class_defs: dict[str, ast.ClassDef] = {}
|
|
2688
2712
|
for node in ast.walk(tree):
|
|
2689
2713
|
if isinstance(node, ast.ClassDef):
|
|
@@ -3105,7 +3129,7 @@ class ASTParser:
|
|
|
3105
3129
|
fake = ast.comprehension(
|
|
3106
3130
|
target=stmt.target, iter=stmt.iter, ifs=[], is_async=0
|
|
3107
3131
|
)
|
|
3108
|
-
params, inner_scope, iterable = self._parse_comp_target(fake, scope)
|
|
3132
|
+
params, inner_scope, iterable, _ = self._parse_comp_target(fake, scope)
|
|
3109
3133
|
|
|
3110
3134
|
body_locals = set(local_vars) | set(params)
|
|
3111
3135
|
body = self._parse_block(stmt.body, body_locals, method_name)
|
|
@@ -4461,7 +4485,7 @@ class ASTParser:
|
|
|
4461
4485
|
f"{fn}() supports a single 'for' clause"
|
|
4462
4486
|
)
|
|
4463
4487
|
comp = gen.generators[0]
|
|
4464
|
-
params, inner_scope, iterable = self._parse_comp_target(comp, scope)
|
|
4488
|
+
params, inner_scope, iterable, _ = self._parse_comp_target(comp, scope)
|
|
4465
4489
|
if comp.ifs:
|
|
4466
4490
|
cond = self._and_conditions(comp.ifs, inner_scope)
|
|
4467
4491
|
iterable = {"expr": "method", "target": iterable, "name": "filter",
|
|
@@ -4570,34 +4594,43 @@ class ASTParser:
|
|
|
4570
4594
|
self, elt: ast.expr, gens: list, i: int, scope: frozenset
|
|
4571
4595
|
) -> Any:
|
|
4572
4596
|
gen = gens[i]
|
|
4573
|
-
params, inner_scope, base = self._parse_comp_target(gen, scope)
|
|
4597
|
+
params, inner_scope, base, safe = self._parse_comp_target(gen, scope)
|
|
4598
|
+
# The loop var is safe-static (its icons are collectable) only when the
|
|
4599
|
+
# iterable is a literal/prop; else it shadows any outer safe var. The
|
|
4600
|
+
# iterable (base) was parsed above in the OUTER scope; the ifs and body
|
|
4601
|
+
# below run with the loop var's safety in effect.
|
|
4602
|
+
saved_safe = self._safe_loop_vars
|
|
4603
|
+
self._safe_loop_vars = ((saved_safe | set(params)) if safe
|
|
4604
|
+
else (saved_safe - set(params)))
|
|
4605
|
+
try:
|
|
4606
|
+
if gen.ifs:
|
|
4607
|
+
cond = self._and_conditions(gen.ifs, inner_scope)
|
|
4608
|
+
base = {
|
|
4609
|
+
"expr": "method", "target": base, "name": "filter",
|
|
4610
|
+
"args": [{"expr": "lambda", "params": params, "body": cond}],
|
|
4611
|
+
}
|
|
4574
4612
|
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4613
|
+
if i + 1 < len(gens):
|
|
4614
|
+
inner = self._build_comprehension(elt, gens, i + 1, inner_scope)
|
|
4615
|
+
return {
|
|
4616
|
+
"expr": "method", "target": base, "name": "flatMap",
|
|
4617
|
+
"args": [{"expr": "lambda", "params": params, "body": inner}],
|
|
4618
|
+
}
|
|
4581
4619
|
|
|
4582
|
-
|
|
4583
|
-
|
|
4620
|
+
# Leaf clause. Skip an identity map: [x for x in it] → it[.filter].
|
|
4621
|
+
if (
|
|
4622
|
+
len(params) == 1
|
|
4623
|
+
and isinstance(elt, ast.Name)
|
|
4624
|
+
and elt.id == params[0]
|
|
4625
|
+
):
|
|
4626
|
+
return base
|
|
4627
|
+
body = self._parse_expr(elt, inner_scope)
|
|
4584
4628
|
return {
|
|
4585
|
-
"expr": "method", "target": base, "name": "
|
|
4586
|
-
"args": [{"expr": "lambda", "params": params, "body":
|
|
4629
|
+
"expr": "method", "target": base, "name": "map",
|
|
4630
|
+
"args": [{"expr": "lambda", "params": params, "body": body}],
|
|
4587
4631
|
}
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
if (
|
|
4591
|
-
len(params) == 1
|
|
4592
|
-
and isinstance(elt, ast.Name)
|
|
4593
|
-
and elt.id == params[0]
|
|
4594
|
-
):
|
|
4595
|
-
return base
|
|
4596
|
-
body = self._parse_expr(elt, inner_scope)
|
|
4597
|
-
return {
|
|
4598
|
-
"expr": "method", "target": base, "name": "map",
|
|
4599
|
-
"args": [{"expr": "lambda", "params": params, "body": body}],
|
|
4600
|
-
}
|
|
4632
|
+
finally:
|
|
4633
|
+
self._safe_loop_vars = saved_safe
|
|
4601
4634
|
|
|
4602
4635
|
def _parse_widget_each(self, comp: ast.expr, scope: frozenset) -> WidgetIR:
|
|
4603
4636
|
"""Lower a spread widget comprehension (``*[W(x) for x in it if c]``) to
|
|
@@ -4610,9 +4643,16 @@ class ASTParser:
|
|
|
4610
4643
|
raise UnsupportedSyntaxError(
|
|
4611
4644
|
"a widget comprehension supports a single 'for' clause"
|
|
4612
4645
|
)
|
|
4613
|
-
params, inner, iterable = self._parse_comp_target(gens[0], scope)
|
|
4614
|
-
|
|
4615
|
-
|
|
4646
|
+
params, inner, iterable, safe = self._parse_comp_target(gens[0], scope)
|
|
4647
|
+
saved_safe = self._safe_loop_vars
|
|
4648
|
+
self._safe_loop_vars = ((saved_safe | set(params)) if safe
|
|
4649
|
+
else (saved_safe - set(params)))
|
|
4650
|
+
try:
|
|
4651
|
+
cond = (self._and_conditions(gens[0].ifs, inner)
|
|
4652
|
+
if gens[0].ifs else None)
|
|
4653
|
+
elt = self._parse_widget(comp.elt, inner)
|
|
4654
|
+
finally:
|
|
4655
|
+
self._safe_loop_vars = saved_safe
|
|
4616
4656
|
return WidgetIR(type="__each", props={
|
|
4617
4657
|
"iter": iterable, "params": params, "cond": cond, "elt": elt,
|
|
4618
4658
|
})
|
|
@@ -4646,14 +4686,16 @@ class ASTParser:
|
|
|
4646
4686
|
val_name = target.elts[1].id
|
|
4647
4687
|
iterable = self._parse_expr(it.args[0], scope)
|
|
4648
4688
|
params = [val_name, idx_name] # JS forEach/map order: value, index
|
|
4649
|
-
return params, scope | {idx_name, val_name}, iterable
|
|
4689
|
+
return (params, scope | {idx_name, val_name}, iterable,
|
|
4690
|
+
self._iterable_is_safe(it.args[0]))
|
|
4650
4691
|
|
|
4651
4692
|
if not isinstance(target, ast.Name):
|
|
4652
4693
|
raise UnsupportedSyntaxError(
|
|
4653
4694
|
"comprehension target must be a name, or (i, x) with enumerate()"
|
|
4654
4695
|
)
|
|
4655
4696
|
iterable = self._parse_expr(it, scope)
|
|
4656
|
-
return [target.id], scope | {target.id}, iterable
|
|
4697
|
+
return ([target.id], scope | {target.id}, iterable,
|
|
4698
|
+
self._iterable_is_safe(it))
|
|
4657
4699
|
|
|
4658
4700
|
def _and_conditions(self, ifs: list, scope: frozenset) -> Any:
|
|
4659
4701
|
parsed = [self._parse_expr(c, scope) for c in ifs]
|
|
@@ -5580,12 +5622,117 @@ class ASTParser:
|
|
|
5580
5622
|
props["name"] = self._icon_name(name_node, scope)
|
|
5581
5623
|
return props
|
|
5582
5624
|
|
|
5625
|
+
def _add_icon_literal(self, raw: str) -> None:
|
|
5626
|
+
"""Record a literal icon name (its base, minus any ``:weight`` suffix)
|
|
5627
|
+
for the build's icon-subsetting."""
|
|
5628
|
+
base = str(raw).partition(":")[0]
|
|
5629
|
+
if re.fullmatch(r"[a-z0-9-]+", base):
|
|
5630
|
+
self.icon_names.add(base)
|
|
5631
|
+
|
|
5632
|
+
def _scan_icon_literals(self, tree: ast.AST) -> None:
|
|
5633
|
+
"""Collect every LITERAL icon name in a source tree — ``icon="…"`` on any
|
|
5634
|
+
call (so a component's icon prop, e.g. ``NavItem(icon="home")``, is
|
|
5635
|
+
caught), ``Icon("…")`` / ``Icon(name="…")``, and ``"icon": "…"`` in dict
|
|
5636
|
+
literals (menu items). Over-collection is harmless: a name absent from
|
|
5637
|
+
icons.css is dropped when subsetting. Whether the FULL sheet is still
|
|
5638
|
+
needed (a state-fed name) is decided per site in ``_icon_name``."""
|
|
5639
|
+
for n in ast.walk(tree):
|
|
5640
|
+
if isinstance(n, ast.Call):
|
|
5641
|
+
fn = n.func
|
|
5642
|
+
fname = (fn.attr if isinstance(fn, ast.Attribute)
|
|
5643
|
+
else getattr(fn, "id", ""))
|
|
5644
|
+
for kw in n.keywords:
|
|
5645
|
+
if kw.arg == "icon" and _is_str_const(kw.value):
|
|
5646
|
+
self._add_icon_literal(kw.value.value)
|
|
5647
|
+
elif (kw.arg == "name" and fname == "Icon"
|
|
5648
|
+
and _is_str_const(kw.value)):
|
|
5649
|
+
self._add_icon_literal(kw.value.value)
|
|
5650
|
+
if fname == "Icon" and n.args and _is_str_const(n.args[0]):
|
|
5651
|
+
self._add_icon_literal(n.args[0].value)
|
|
5652
|
+
elif isinstance(n, ast.Dict):
|
|
5653
|
+
for k, v in zip(n.keys, n.values):
|
|
5654
|
+
if (_is_str_const(k) and k.value == "icon"
|
|
5655
|
+
and _is_str_const(v)):
|
|
5656
|
+
self._add_icon_literal(v.value)
|
|
5657
|
+
elif isinstance(n, (ast.List, ast.Tuple)):
|
|
5658
|
+
# Icon-shaped string elements — e.g. NavItem's actions=["pin",
|
|
5659
|
+
# "star"], iterated into per-item icons. Each literal is
|
|
5660
|
+
# collected (a mixed list still contributes its literals);
|
|
5661
|
+
# non-icon strings are dropped at subset time.
|
|
5662
|
+
for e in n.elts:
|
|
5663
|
+
if (_is_str_const(e)
|
|
5664
|
+
and re.fullmatch(r"[a-z0-9-]+",
|
|
5665
|
+
e.value.partition(":")[0])):
|
|
5666
|
+
self._add_icon_literal(e.value)
|
|
5667
|
+
elif isinstance(n, ast.FunctionDef):
|
|
5668
|
+
# A component's literal ``icon=`` default — ``def __init__(self,
|
|
5669
|
+
# …, icon="circle")`` — renders when the prop is omitted, so
|
|
5670
|
+
# collect it. Defaults align to the tail of the positional args;
|
|
5671
|
+
# keyword-only defaults pair 1:1 (None where absent).
|
|
5672
|
+
a = n.args
|
|
5673
|
+
pairs = list(zip(a.args[len(a.args) - len(a.defaults):],
|
|
5674
|
+
a.defaults))
|
|
5675
|
+
pairs += list(zip(a.kwonlyargs, a.kw_defaults))
|
|
5676
|
+
for param, default in pairs:
|
|
5677
|
+
if param.arg == "icon" and _is_str_const(default):
|
|
5678
|
+
self._add_icon_literal(default.value)
|
|
5679
|
+
|
|
5680
|
+
def _icon_expr_is_dynamic(self, node: ast.expr, scope: frozenset) -> bool:
|
|
5681
|
+
"""Whether a NON-literal icon name can vary at runtime, so the full icon
|
|
5682
|
+
set must be kept. A component-prop reference — ``self.<prop>``, or a
|
|
5683
|
+
subscript/attribute rooted at one (the SDK NavItem's ``Icon(self.icon)``
|
|
5684
|
+
and ``Icon(self.item["icon"])``) — is NOT dynamic: its literal values are
|
|
5685
|
+
collected from the call sites. A state read (``self.<state>``), an
|
|
5686
|
+
f-string, a call or any other expression IS, so the build safely falls
|
|
5687
|
+
back to the CDN full set. Component props live in ``_current_props`` (the
|
|
5688
|
+
``scope`` arg is the local/loop vars in effect)."""
|
|
5689
|
+
props = self._current_props
|
|
5690
|
+
n = node
|
|
5691
|
+
while isinstance(n, (ast.Subscript, ast.Attribute)):
|
|
5692
|
+
if (isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name)
|
|
5693
|
+
and n.value.id == "self"):
|
|
5694
|
+
return n.attr not in props # a prop → static; else state
|
|
5695
|
+
n = n.value
|
|
5696
|
+
if isinstance(n, ast.Name):
|
|
5697
|
+
# A bare local/loop variable (or its subscript/attribute) — e.g.
|
|
5698
|
+
# `for a in self.actions: Icon(a)`. Static ONLY when it iterates a
|
|
5699
|
+
# statically known set (a list literal, or a prop): those loop vars
|
|
5700
|
+
# are in _safe_loop_vars. A loop over State (or a call) is NOT, so
|
|
5701
|
+
# it's dynamic and the build keeps the full sheet.
|
|
5702
|
+
return n.id not in self._safe_loop_vars
|
|
5703
|
+
return True # f-string, call, etc. → dynamic
|
|
5704
|
+
|
|
5705
|
+
def _iterable_is_safe(self, node: ast.expr) -> bool:
|
|
5706
|
+
"""A comprehension iterable whose per-item icon names are statically
|
|
5707
|
+
collectable: a list/tuple of string LITERALS (scanned), or a component
|
|
5708
|
+
prop (``self.<prop>``, fed literals at the call sites). A State read, a
|
|
5709
|
+
mixed list, a call (a fetch) or an unknown expression is NOT — a loop var
|
|
5710
|
+
over it is runtime-dynamic, so the build falls back to the full sheet."""
|
|
5711
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
5712
|
+
return all(_is_str_const(e) for e in node.elts)
|
|
5713
|
+
if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)
|
|
5714
|
+
and node.value.id == "self"):
|
|
5715
|
+
return node.attr in self._current_props
|
|
5716
|
+
return False
|
|
5717
|
+
|
|
5718
|
+
def _note_component_icon_prop(
|
|
5719
|
+
self, prop_name: str, value_node: ast.expr, scope: frozenset
|
|
5720
|
+
) -> None:
|
|
5721
|
+
"""A component instance's ``icon=`` prop. Literal values are already
|
|
5722
|
+
collected by the scan; a NON-literal that is state-derived (not a prop
|
|
5723
|
+
passthrough like ``NavItem(icon=self.icon)``) can't be known at build
|
|
5724
|
+
time, so keep the full icon sheet."""
|
|
5725
|
+
if (prop_name == "icon" and not _is_str_const(value_node)
|
|
5726
|
+
and self._icon_expr_is_dynamic(value_node, scope)):
|
|
5727
|
+
self.dynamic_icons = True
|
|
5728
|
+
|
|
5583
5729
|
def _icon_name(self, node: ast.expr, scope: frozenset) -> Any:
|
|
5584
5730
|
"""Validate a Tabler icon name (the Icon widget's name, or a Button/Link
|
|
5585
5731
|
``icon=``). A string literal must be ``[a-z0-9-]`` (a clear error for
|
|
5586
5732
|
typos); a dynamic value (state / f-string → a dict expression) is allowed
|
|
5587
5733
|
and sanitised to the same charset by the runtime, so it can only ever
|
|
5588
|
-
produce a safe class.
|
|
5734
|
+
produce a safe class. Both feed the build's icon-subsetting: a literal is
|
|
5735
|
+
recorded, a state-fed name keeps the full sheet (see _icon_expr_is_dynamic)."""
|
|
5589
5736
|
name = self._parse_text_value(node, scope)
|
|
5590
5737
|
if isinstance(name, str):
|
|
5591
5738
|
base, sep, suffix = name.partition(":")
|
|
@@ -5602,6 +5749,10 @@ class ASTParser:
|
|
|
5602
5749
|
"with a stroke-weight suffix like 'arrow-left:2'; "
|
|
5603
5750
|
f"got: {name!r}"
|
|
5604
5751
|
)
|
|
5752
|
+
self.icon_names.add(base)
|
|
5753
|
+
elif isinstance(name, dict):
|
|
5754
|
+
if self._icon_expr_is_dynamic(node, scope):
|
|
5755
|
+
self.dynamic_icons = True
|
|
5605
5756
|
if not isinstance(name, (str, dict)):
|
|
5606
5757
|
raise UnsupportedSyntaxError(f"icon must be a string; got: {name!r}")
|
|
5607
5758
|
return name
|
|
@@ -82,7 +82,9 @@ _LISTVIEW_PROPS = {
|
|
|
82
82
|
class JSGenerator:
|
|
83
83
|
|
|
84
84
|
#: Set True while generating if the app renders any ``Icon`` — the build
|
|
85
|
-
#: reads it to link the icon stylesheet
|
|
85
|
+
#: reads it to link the icon stylesheet only when it's needed. WHICH icons
|
|
86
|
+
#: (for subsetting) and whether any is dynamic are collected by the parser
|
|
87
|
+
#: (ASTParser.icon_names / .dynamic_icons), which sees component icon props.
|
|
86
88
|
uses_icons = False
|
|
87
89
|
|
|
88
90
|
def generate(self, ir: "AppIR | RouterIR") -> str:
|
|
Binary file
|
|
@@ -43,6 +43,7 @@ src/workpeg/ui/compiler/ast_parser.py
|
|
|
43
43
|
src/workpeg/ui/compiler/ir.py
|
|
44
44
|
src/workpeg/ui/compiler/js_generator.py
|
|
45
45
|
src/workpeg/ui/runtime/ai.js
|
|
46
|
+
src/workpeg/ui/runtime/icons.css.gz
|
|
46
47
|
src/workpeg/ui/runtime/pegment.css
|
|
47
48
|
src/workpeg/ui/runtime/scaffold.js
|
|
48
49
|
src/workpeg/ui/runtime/workpeg-runtime.js
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|