evenis 0.1.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.
- evenis/__init__.py +102 -0
- evenis/__main__.py +4 -0
- evenis/cli.py +129 -0
- evenis/compiler/__init__.py +134 -0
- evenis/compiler/html.py +120 -0
- evenis/compiler/runtime.py +452 -0
- evenis/components/__init__.py +214 -0
- evenis/core/__init__.py +369 -0
- evenis/layout/__init__.py +283 -0
- evenis/router/__init__.py +36 -0
- evenis/server/__init__.py +239 -0
- evenis/server/runner.py +254 -0
- evenis/state/__init__.py +117 -0
- evenis/style/__init__.py +116 -0
- evenis/style/css.py +267 -0
- evenis-0.1.0.dist-info/METADATA +86 -0
- evenis-0.1.0.dist-info/RECORD +20 -0
- evenis-0.1.0.dist-info/WHEEL +5 -0
- evenis-0.1.0.dist-info/entry_points.txt +2 -0
- evenis-0.1.0.dist-info/top_level.txt +1 -0
evenis/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""evenis — production-grade Python UI framework.
|
|
2
|
+
|
|
3
|
+
Двухслойная система:
|
|
4
|
+
• compile-time (Python): UI-дерево (AST) → layout → theme→CSS → HTML → JS inject;
|
|
5
|
+
• runtime (browser JS): SPA-роутинг, реактивный store, DOM-обновления, анимации.
|
|
6
|
+
|
|
7
|
+
Пользователь описывает UI ТОЛЬКО на Python — без HTML/CSS/JS.
|
|
8
|
+
|
|
9
|
+
Пример:
|
|
10
|
+
import evenis as ui
|
|
11
|
+
|
|
12
|
+
app = ui.App(title="Demo", state={"count": 0})
|
|
13
|
+
|
|
14
|
+
@app.page("/", title="Home")
|
|
15
|
+
def home():
|
|
16
|
+
return ui.Column(
|
|
17
|
+
ui.Heading("Счётчик"),
|
|
18
|
+
ui.Text("Значение: {{ count }}"),
|
|
19
|
+
ui.Button("+1", on_click=ui.increment("count"), variant="primary"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.build("dist")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .core import App, Component, Element, For, Fragment, If, Node, Page, to_vnode
|
|
26
|
+
from .layout import (
|
|
27
|
+
AdaptiveLayout,
|
|
28
|
+
Column,
|
|
29
|
+
Container,
|
|
30
|
+
Grid,
|
|
31
|
+
Motion,
|
|
32
|
+
Responsive,
|
|
33
|
+
Row,
|
|
34
|
+
Scrollable,
|
|
35
|
+
Spacer,
|
|
36
|
+
Stack,
|
|
37
|
+
)
|
|
38
|
+
from .components import (
|
|
39
|
+
Button,
|
|
40
|
+
Card,
|
|
41
|
+
DangerButton,
|
|
42
|
+
Divider,
|
|
43
|
+
GhostButton,
|
|
44
|
+
Heading,
|
|
45
|
+
Image,
|
|
46
|
+
Input,
|
|
47
|
+
Link,
|
|
48
|
+
PrimaryButton,
|
|
49
|
+
Text,
|
|
50
|
+
)
|
|
51
|
+
from .style import Theme, default_theme, light_theme
|
|
52
|
+
from .state import (
|
|
53
|
+
append,
|
|
54
|
+
batch,
|
|
55
|
+
decrement,
|
|
56
|
+
fetch_json,
|
|
57
|
+
increment,
|
|
58
|
+
js,
|
|
59
|
+
log,
|
|
60
|
+
navigate,
|
|
61
|
+
prefetch,
|
|
62
|
+
redirect_if,
|
|
63
|
+
remove_at,
|
|
64
|
+
set_from_event,
|
|
65
|
+
set_value,
|
|
66
|
+
toggle,
|
|
67
|
+
)
|
|
68
|
+
from .router import Router
|
|
69
|
+
from .compiler import build_app, build_manifest
|
|
70
|
+
from .server import (
|
|
71
|
+
LiveState,
|
|
72
|
+
action,
|
|
73
|
+
enable_auth,
|
|
74
|
+
protected,
|
|
75
|
+
serve,
|
|
76
|
+
server,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__version__ = "0.1.0"
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
# core
|
|
83
|
+
"App", "Page", "Component", "Element", "Node", "Fragment", "If", "For", "to_vnode",
|
|
84
|
+
# layout
|
|
85
|
+
"Row", "Column", "Grid", "Stack", "Container", "Spacer", "Responsive",
|
|
86
|
+
"AdaptiveLayout", "Scrollable", "Motion",
|
|
87
|
+
# components
|
|
88
|
+
"Text", "Heading", "Button", "PrimaryButton", "DangerButton", "GhostButton",
|
|
89
|
+
"Card", "Input", "Link", "Image", "Divider",
|
|
90
|
+
# style
|
|
91
|
+
"Theme", "default_theme", "light_theme",
|
|
92
|
+
# state / actions
|
|
93
|
+
"set_value", "increment", "decrement", "toggle", "set_from_event",
|
|
94
|
+
"append", "remove_at", "navigate", "js", "fetch_json", "batch",
|
|
95
|
+
# middleware
|
|
96
|
+
"redirect_if", "log", "prefetch",
|
|
97
|
+
# router / compiler
|
|
98
|
+
"Router", "build_app", "build_manifest",
|
|
99
|
+
# server (fullstack)
|
|
100
|
+
"server", "action", "LiveState", "protected", "enable_auth", "serve",
|
|
101
|
+
"__version__",
|
|
102
|
+
]
|
evenis/__main__.py
ADDED
evenis/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""evenis.cli — одна команда для сборки и dev-режима.
|
|
2
|
+
|
|
3
|
+
evenis build app.py [--out dist] — собрать сайт в каталог.
|
|
4
|
+
evenis dev app.py [--out dist] [--port 8000]
|
|
5
|
+
— собрать с dev=True (hot reload), поднять статический сервер и
|
|
6
|
+
пересобирать при изменении исходников (polling watcher).
|
|
7
|
+
|
|
8
|
+
`app.py` должен экспортировать переменную `app` (экземпляр evenis.App) либо
|
|
9
|
+
функцию `app()`/`create_app()`, возвращающую App.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import importlib.util
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_app(path: str):
|
|
22
|
+
spec = importlib.util.spec_from_file_location("_evenis_user_app", path)
|
|
23
|
+
if spec is None or spec.loader is None:
|
|
24
|
+
raise SystemExit(f"Не удалось загрузить модуль: {path}")
|
|
25
|
+
mod = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(mod)
|
|
27
|
+
candidate = getattr(mod, "app", None) or getattr(mod, "create_app", None)
|
|
28
|
+
if candidate is None:
|
|
29
|
+
raise SystemExit("В модуле нет `app` (экземпляр App) или фабрики create_app().")
|
|
30
|
+
app = candidate() if callable(candidate) else candidate
|
|
31
|
+
return app
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _snapshot(root: str) -> float:
|
|
35
|
+
"""Максимальное mtime по дереву *.py — простой watcher без зависимостей."""
|
|
36
|
+
latest = 0.0
|
|
37
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
38
|
+
if "__pycache__" in dirpath or "/dist" in dirpath:
|
|
39
|
+
continue
|
|
40
|
+
for f in files:
|
|
41
|
+
if f.endswith(".py"):
|
|
42
|
+
try:
|
|
43
|
+
latest = max(latest, os.path.getmtime(os.path.join(dirpath, f)))
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
return latest
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_build(args) -> None:
|
|
50
|
+
app = _load_app(args.entry)
|
|
51
|
+
out = app.build(args.out)
|
|
52
|
+
print(f"evenis: собрано → {out}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_dev(args) -> None:
|
|
56
|
+
import functools
|
|
57
|
+
import http.server
|
|
58
|
+
import socketserver
|
|
59
|
+
import threading
|
|
60
|
+
|
|
61
|
+
entry = os.path.abspath(args.entry)
|
|
62
|
+
watch_root = os.path.dirname(entry)
|
|
63
|
+
|
|
64
|
+
def rebuild():
|
|
65
|
+
app = _load_app(entry)
|
|
66
|
+
app.dev = True
|
|
67
|
+
app.build(args.out)
|
|
68
|
+
|
|
69
|
+
rebuild()
|
|
70
|
+
out_abs = os.path.abspath(args.out)
|
|
71
|
+
print(f"evenis: dev-сборка → {out_abs}")
|
|
72
|
+
|
|
73
|
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=out_abs)
|
|
74
|
+
httpd = socketserver.TCPServer(("", args.port), handler)
|
|
75
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
76
|
+
print(f"evenis: http://localhost:{args.port} (Ctrl+C для выхода)")
|
|
77
|
+
|
|
78
|
+
last = _snapshot(watch_root)
|
|
79
|
+
try:
|
|
80
|
+
while True:
|
|
81
|
+
time.sleep(0.6)
|
|
82
|
+
cur = _snapshot(watch_root)
|
|
83
|
+
if cur > last:
|
|
84
|
+
last = cur
|
|
85
|
+
try:
|
|
86
|
+
rebuild()
|
|
87
|
+
print("evenis: пересобрано, обновляю браузер…")
|
|
88
|
+
except Exception as e: # noqa: BLE001 - dev-цикл не должен падать
|
|
89
|
+
print(f"evenis: ошибка сборки: {e}", file=sys.stderr)
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
print("\nevenis: остановлено.")
|
|
92
|
+
httpd.shutdown()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_serve(args) -> None:
|
|
96
|
+
from .server import serve
|
|
97
|
+
|
|
98
|
+
app = _load_app(args.entry)
|
|
99
|
+
serve(app, outdir=args.out, port=args.port, host=args.host)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv=None) -> None:
|
|
103
|
+
parser = argparse.ArgumentParser(prog="evenis", description="evenis — Python UI framework")
|
|
104
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
105
|
+
|
|
106
|
+
b = sub.add_parser("build", help="собрать сайт в каталог")
|
|
107
|
+
b.add_argument("entry", help="путь к app.py (экспортирует app)")
|
|
108
|
+
b.add_argument("--out", default="dist", help="каталог вывода (по умолчанию dist)")
|
|
109
|
+
b.set_defaults(func=cmd_build)
|
|
110
|
+
|
|
111
|
+
d = sub.add_parser("dev", help="dev-сервер с hot reload")
|
|
112
|
+
d.add_argument("entry", help="путь к app.py (экспортирует app)")
|
|
113
|
+
d.add_argument("--out", default="dist", help="каталог вывода")
|
|
114
|
+
d.add_argument("--port", type=int, default=8000, help="порт (по умолчанию 8000)")
|
|
115
|
+
d.set_defaults(func=cmd_dev)
|
|
116
|
+
|
|
117
|
+
s = sub.add_parser("serve", help="fullstack-сервер (фронт + FastAPI API + WebSocket)")
|
|
118
|
+
s.add_argument("entry", help="путь к app.py (экспортирует app)")
|
|
119
|
+
s.add_argument("--out", default="dist", help="каталог вывода")
|
|
120
|
+
s.add_argument("--port", type=int, default=8000, help="желаемый порт (свободный подберётся рядом)")
|
|
121
|
+
s.add_argument("--host", default="127.0.0.1", help="хост (по умолчанию 127.0.0.1)")
|
|
122
|
+
s.set_defaults(func=cmd_serve)
|
|
123
|
+
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
args.func(args)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""evenis.compiler — конвейер компиляции (compile-time слой).
|
|
2
|
+
|
|
3
|
+
Этапы:
|
|
4
|
+
1. AST: собрать VNode-дерево каждой страницы (App.pages → manifest маршрутов).
|
|
5
|
+
2. layout resolve: уже выполнен при построении VNode (Row/Column/... → классы).
|
|
6
|
+
3. CSS generator: theme → style.css.
|
|
7
|
+
4. HTML generator: SSR начального маршрута → index.html (первый кадр).
|
|
8
|
+
5. JS runtime injector: runtime + манифест приложения → app.js.
|
|
9
|
+
|
|
10
|
+
Результат пишется в каталог `outdir` (по умолчанию dist/):
|
|
11
|
+
dist/index.html, dist/style.css, dist/app.js
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from ..router import build_routes
|
|
21
|
+
from ..style.css import generate_css
|
|
22
|
+
from .html import render_page_html
|
|
23
|
+
from .runtime import RUNTIME_JS
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
|
+
from ..core import App
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_manifest(app: "App") -> dict:
|
|
30
|
+
"""Собрать JSON-манифест приложения для рантайма."""
|
|
31
|
+
routes = build_routes(app.pages)
|
|
32
|
+
if not routes:
|
|
33
|
+
raise ValueError("В приложении нет страниц: добавьте хотя бы одну Page")
|
|
34
|
+
fallback = app.pages[0].path
|
|
35
|
+
computed = [{"name": k, "expr": v} for k, v in app.computed.items()]
|
|
36
|
+
manifest = {
|
|
37
|
+
"title": app.title,
|
|
38
|
+
"mount": "app",
|
|
39
|
+
"state": app.state,
|
|
40
|
+
"routes": routes,
|
|
41
|
+
"fallback": fallback,
|
|
42
|
+
"breakpoint": app.theme.breakpoint,
|
|
43
|
+
"computed": computed,
|
|
44
|
+
"middleware": app.middleware,
|
|
45
|
+
"features": app.features,
|
|
46
|
+
"dev": app.dev,
|
|
47
|
+
}
|
|
48
|
+
# Серверный слой (если использовались @server/@action/LiveState).
|
|
49
|
+
try:
|
|
50
|
+
from ..server import REGISTRY, server_manifest
|
|
51
|
+
|
|
52
|
+
if REGISTRY.endpoints or REGISTRY.livestates:
|
|
53
|
+
sm = server_manifest()
|
|
54
|
+
manifest["server"] = sm
|
|
55
|
+
# начальные значения LiveState кладём прямо в state, чтобы первый кадр
|
|
56
|
+
# уже показывал актуальные данные ещё до WebSocket-подключения.
|
|
57
|
+
for k, v in sm.get("live", {}).items():
|
|
58
|
+
manifest["state"].setdefault(k, v)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
# Загрузка данных при входе на страницу (Page.load → server endpoint).
|
|
62
|
+
for path, route in routes.items():
|
|
63
|
+
page = next((p for p in app.pages if p.path == path), None)
|
|
64
|
+
if page is not None and getattr(page, "load", None):
|
|
65
|
+
route["load"] = page.load
|
|
66
|
+
return manifest
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_HOT_RELOAD = """
|
|
70
|
+
<script>
|
|
71
|
+
(function(){
|
|
72
|
+
var cur=null;
|
|
73
|
+
setInterval(function(){
|
|
74
|
+
fetch('__evenis_buildid?ts='+Date.now()).then(function(r){return r.text();}).then(function(id){
|
|
75
|
+
if(cur===null){cur=id;return;} if(id!==cur){location.reload();}
|
|
76
|
+
}).catch(function(){});
|
|
77
|
+
}, 800);
|
|
78
|
+
})();
|
|
79
|
+
</script>"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _index_html(app: "App", manifest: dict) -> str:
|
|
83
|
+
initial_path = manifest["fallback"]
|
|
84
|
+
initial_page = manifest["routes"][initial_path]
|
|
85
|
+
ssr = render_page_html(initial_page, app.state)
|
|
86
|
+
title = manifest["routes"][initial_path].get("title", app.title)
|
|
87
|
+
hot = _HOT_RELOAD if app.dev else ""
|
|
88
|
+
return f"""<!doctype html>
|
|
89
|
+
<html lang="ru">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8" />
|
|
92
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
93
|
+
<title>{title} · {app.title}</title>
|
|
94
|
+
<link rel="stylesheet" href="style.css" />
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div id="app">{ssr}</div>
|
|
98
|
+
<script src="app.js"></script>{hot}
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _app_js(manifest: dict) -> str:
|
|
105
|
+
data = json.dumps(manifest, ensure_ascii=False)
|
|
106
|
+
return RUNTIME_JS + "\n;window.EVENIS.start(" + data + ");\n"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_app(app: "App", outdir: str = "dist") -> str:
|
|
110
|
+
"""Скомпилировать App в готовый сайт. Возвращает абсолютный путь к outdir."""
|
|
111
|
+
os.makedirs(outdir, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
manifest = build_manifest(app)
|
|
114
|
+
|
|
115
|
+
css = generate_css(app.theme, features=app.features)
|
|
116
|
+
html_doc = _index_html(app, manifest)
|
|
117
|
+
js = _app_js(manifest)
|
|
118
|
+
|
|
119
|
+
with open(os.path.join(outdir, "style.css"), "w", encoding="utf-8") as f:
|
|
120
|
+
f.write(css)
|
|
121
|
+
with open(os.path.join(outdir, "index.html"), "w", encoding="utf-8") as f:
|
|
122
|
+
f.write(html_doc)
|
|
123
|
+
with open(os.path.join(outdir, "app.js"), "w", encoding="utf-8") as f:
|
|
124
|
+
f.write(js)
|
|
125
|
+
if app.dev:
|
|
126
|
+
import time
|
|
127
|
+
|
|
128
|
+
with open(os.path.join(outdir, "__evenis_buildid"), "w", encoding="utf-8") as f:
|
|
129
|
+
f.write(str(time.time()))
|
|
130
|
+
|
|
131
|
+
return os.path.abspath(outdir)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["build_app", "build_manifest"]
|
evenis/compiler/html.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""evenis.compiler.html — генератор HTML из VNode (server-side, первый кадр).
|
|
2
|
+
|
|
3
|
+
Рендерит VNode-дерево в строку HTML, вычисляя биндинги/if/for на начальном
|
|
4
|
+
state. Это нужно для мгновенного первого кадра и graceful-деградации без JS;
|
|
5
|
+
дальше управление перехватывает рантайм. Логика интерпретации намеренно
|
|
6
|
+
совпадает с рантаймом (см. runtime.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
_VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
|
|
16
|
+
_BINDING = re.compile(r"\{\{\s*(.*?)\s*\}\}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _eval(expr: str, scope: Dict[str, Any]) -> Any:
|
|
20
|
+
"""Безопасно вычислить выражение над scope (подмножество, без builtins)."""
|
|
21
|
+
try:
|
|
22
|
+
return eval(expr, {"__builtins__": {}}, dict(scope)) # noqa: S307 - sandboxed scope
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _interpolate(text: str, scope: Dict[str, Any]) -> str:
|
|
28
|
+
def repl(m):
|
|
29
|
+
val = _eval(m.group(1), scope)
|
|
30
|
+
return "" if val is None else str(val)
|
|
31
|
+
|
|
32
|
+
return _BINDING.sub(repl, text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _render_attrs(props: Dict[str, Any], scope: Dict[str, Any]) -> str:
|
|
36
|
+
out = []
|
|
37
|
+
for k, v in props.items():
|
|
38
|
+
if v is True:
|
|
39
|
+
out.append(f" {k}")
|
|
40
|
+
elif v is False or v is None:
|
|
41
|
+
continue
|
|
42
|
+
else:
|
|
43
|
+
val = _interpolate(str(v), scope)
|
|
44
|
+
out.append(f' {k}="{html.escape(val, quote=True)}"')
|
|
45
|
+
return "".join(out)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_vnode(vnode: Any, scope: Dict[str, Any]) -> str:
|
|
49
|
+
if vnode is None:
|
|
50
|
+
return ""
|
|
51
|
+
t = vnode.get("type")
|
|
52
|
+
|
|
53
|
+
if t == "text":
|
|
54
|
+
return html.escape(_interpolate(vnode.get("value", ""), scope))
|
|
55
|
+
|
|
56
|
+
if t == "fragment":
|
|
57
|
+
return "".join(render_vnode(c, scope) for c in vnode.get("children", []))
|
|
58
|
+
|
|
59
|
+
if t == "if":
|
|
60
|
+
if _eval(vnode["cond"], scope):
|
|
61
|
+
return render_vnode(vnode["then"], scope)
|
|
62
|
+
return render_vnode(vnode["else"], scope) if vnode.get("else") else ""
|
|
63
|
+
|
|
64
|
+
if t == "for":
|
|
65
|
+
items = _eval(vnode["items"], scope)
|
|
66
|
+
if not isinstance(items, (list, tuple)):
|
|
67
|
+
return ""
|
|
68
|
+
var, idx = vnode.get("var", "item"), vnode.get("index", "index")
|
|
69
|
+
chunks = []
|
|
70
|
+
for i, it in enumerate(items):
|
|
71
|
+
inner = dict(scope)
|
|
72
|
+
inner[var] = it
|
|
73
|
+
inner[idx] = i
|
|
74
|
+
chunks.append(render_vnode(vnode["body"], inner))
|
|
75
|
+
return "".join(chunks)
|
|
76
|
+
|
|
77
|
+
if t == "responsive":
|
|
78
|
+
# SSR рендерит desktop-ветку (рантайм переопределит по ширине окна)
|
|
79
|
+
return render_vnode(vnode["desktop"], scope)
|
|
80
|
+
|
|
81
|
+
if t == "adaptive":
|
|
82
|
+
# SSR — статичная сетка; рантайм пересчитает число колонок по ширине окна
|
|
83
|
+
gap = vnode.get("gap", 16)
|
|
84
|
+
max_cols = vnode.get("maxCols", 6)
|
|
85
|
+
inner = "".join(render_vnode(c, scope) for c in vnode.get("children", []))
|
|
86
|
+
n = len(vnode.get("children", []))
|
|
87
|
+
cols = max(1, min(max_cols, n or 1))
|
|
88
|
+
style = (
|
|
89
|
+
f"display:grid; gap:{gap}px; "
|
|
90
|
+
f"grid-template-columns:repeat({cols},minmax(0,1fr)); padding:16px"
|
|
91
|
+
)
|
|
92
|
+
return f'<div class="evenis-adaptive" style="{html.escape(style, quote=True)}">{inner}</div>'
|
|
93
|
+
|
|
94
|
+
if t == "page":
|
|
95
|
+
cls = f"evenis-page evenis-anim-{vnode.get('transition', 'fade-in')}"
|
|
96
|
+
return f'<div class="{cls}" data-page="{html.escape(vnode.get("path",""))}">' + render_vnode(vnode["child"], scope) + "</div>"
|
|
97
|
+
|
|
98
|
+
if t == "element":
|
|
99
|
+
tag = vnode["tag"]
|
|
100
|
+
props = dict(vnode.get("props", {}))
|
|
101
|
+
# bindClass: добавить классы по условиям
|
|
102
|
+
bind_class = vnode.get("bindClass")
|
|
103
|
+
if bind_class:
|
|
104
|
+
extra = [cn for cn, expr in bind_class.items() if _eval(expr, scope)]
|
|
105
|
+
if extra:
|
|
106
|
+
props["class"] = (props.get("class", "") + " " + " ".join(extra)).strip()
|
|
107
|
+
attrs = _render_attrs(props, scope)
|
|
108
|
+
if tag in _VOID:
|
|
109
|
+
return f"<{tag}{attrs}>"
|
|
110
|
+
inner = "".join(render_vnode(c, scope) for c in vnode.get("children", []))
|
|
111
|
+
return f"<{tag}{attrs}>{inner}</{tag}>"
|
|
112
|
+
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_page_html(page_vnode: dict, state: Dict[str, Any]) -> str:
|
|
117
|
+
return render_vnode(page_vnode, state)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["render_vnode", "render_page_html"]
|