xania 0.1.2__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.
- xania/__init__.py +23 -0
- xania/__main__.py +8 -0
- xania/cli.py +330 -0
- xania/engine/runtime.py +50 -0
- xania/engine/serializer.py +60 -0
- xania/example/counter.py +49 -0
- xania/renderer/__init__.py +12 -0
- xania/renderer/component.py +46 -0
- xania/renderer/elements.py +323 -0
- xania/renderer/index.html +64 -0
- xania/renderer/registry.py +34 -0
- xania/renderer/render.py +12 -0
- xania/renderer/state.py +154 -0
- xania/renderer/test.html +511 -0
- xania/runtime/__init__.py +22 -0
- xania/runtime/compiler.py +95 -0
- xania/runtime/spa.py +119 -0
- xania/static/app.js +1115 -0
- xania/static/runtime.js +113 -0
- xania/static/spa_runtime.js +271 -0
- xania/web/app.py +40 -0
- xania/web/auth.py +239 -0
- xania/web/config.py +43 -0
- xania/web/ratelimit.py +46 -0
- xania/web/routes.py +248 -0
- xania/web/schemas.py +24 -0
- xania/web/serve.py +61 -0
- xania-0.1.2.dist-info/METADATA +485 -0
- xania-0.1.2.dist-info/RECORD +33 -0
- xania-0.1.2.dist-info/WHEEL +5 -0
- xania-0.1.2.dist-info/entry_points.txt +2 -0
- xania-0.1.2.dist-info/licenses/LICENSE +22 -0
- xania-0.1.2.dist-info/top_level.txt +1 -0
xania/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from xania.renderer.component import Component
|
|
2
|
+
from xania.renderer.elements import (
|
|
3
|
+
Div, Button, H1, H2, H3, H4, H5, H6, P, Span,
|
|
4
|
+
Input, Textarea, Form, Label, Ul, Ol, Li, A, Img
|
|
5
|
+
)
|
|
6
|
+
from xania.renderer.state import State, useState, useRef
|
|
7
|
+
from xania.runtime.spa import SpaApp, StaticPage, TemplatePage, JsExpr
|
|
8
|
+
from xania.runtime.compiler import SpaCompiler
|
|
9
|
+
from xania.web.serve import mount_spa
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# Components
|
|
13
|
+
"Component",
|
|
14
|
+
# Elements
|
|
15
|
+
"Div", "Button", "H1", "H2", "H3", "H4", "H5", "H6", "P", "Span",
|
|
16
|
+
"Input", "Textarea", "Form", "Label", "Ul", "Ol", "Li", "A", "Img",
|
|
17
|
+
# State
|
|
18
|
+
"State", "useState", "useRef",
|
|
19
|
+
# SPA
|
|
20
|
+
"SpaApp", "StaticPage", "TemplatePage", "JsExpr", "SpaCompiler",
|
|
21
|
+
# Serving
|
|
22
|
+
"mount_spa",
|
|
23
|
+
]
|
xania/__main__.py
ADDED
xania/cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CliConfig:
|
|
14
|
+
default_app: str = "xania.web.app:app"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_uvicorn(app: str, host: str, port: int, reload: bool) -> None:
|
|
18
|
+
try:
|
|
19
|
+
import uvicorn
|
|
20
|
+
|
|
21
|
+
uvicorn.run(app, host=host, port=int(port), reload=reload)
|
|
22
|
+
except Exception as e: # pragma: no cover
|
|
23
|
+
raise click.ClickException(f"Failed to start server: {e}") from e
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_spa_index_html(dest: Path, *, title: str, tailwind: bool) -> None:
|
|
27
|
+
tw = ' <script src="https://cdn.tailwindcss.com"></script>\n' if tailwind else ""
|
|
28
|
+
html = f"""<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="UTF-8" />
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
33
|
+
<title>{title}</title>
|
|
34
|
+
{tw} </head>
|
|
35
|
+
<body>
|
|
36
|
+
<div id="app"></div>
|
|
37
|
+
<script src="/static/spa_runtime.js"></script>
|
|
38
|
+
<script src="/static/app.js"></script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
"""
|
|
42
|
+
dest.write_text(html, encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _copy_package_static(out_dir: Path) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Copy packaged static assets into `out_dir/static`.
|
|
48
|
+
|
|
49
|
+
Uses importlib.resources so it works when installed from a wheel.
|
|
50
|
+
"""
|
|
51
|
+
from importlib.resources import as_file, files
|
|
52
|
+
|
|
53
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
dest_static = out_dir / "static"
|
|
55
|
+
if dest_static.exists():
|
|
56
|
+
shutil.rmtree(dest_static)
|
|
57
|
+
|
|
58
|
+
static_root = files("xania").joinpath("static")
|
|
59
|
+
with as_file(static_root) as static_path:
|
|
60
|
+
shutil.copytree(static_path, dest_static)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group(invoke_without_command=True)
|
|
64
|
+
@click.option("--app", "app_path", default=None, help="ASGI app path, e.g. module:app")
|
|
65
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
66
|
+
@click.option("--port", default=8000, show_default=True, type=int)
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def cli(ctx: click.Context, app_path: Optional[str], host: str, port: int) -> None:
|
|
69
|
+
"""Xania CLI — Python UI framework for building SPAs."""
|
|
70
|
+
if ctx.invoked_subcommand is not None:
|
|
71
|
+
return
|
|
72
|
+
cfg = CliConfig()
|
|
73
|
+
_run_uvicorn(app_path or cfg.default_app, host=host, port=port, reload=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cli.command("init")
|
|
77
|
+
@click.argument("project_name", default="my_spa")
|
|
78
|
+
def init(project_name: str) -> None:
|
|
79
|
+
"""Create a new Xania SPA project.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
xania init my_website
|
|
83
|
+
"""
|
|
84
|
+
project_path = Path.cwd() / project_name
|
|
85
|
+
|
|
86
|
+
if project_path.exists():
|
|
87
|
+
raise click.ClickException(f"Directory {project_name} already exists")
|
|
88
|
+
|
|
89
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# Create main app file
|
|
92
|
+
app_py = project_path / "app.py"
|
|
93
|
+
app_py.write_text("""\"\"\"
|
|
94
|
+
My Xania Website
|
|
95
|
+
|
|
96
|
+
Run this file to compile to static files:
|
|
97
|
+
python app.py
|
|
98
|
+
|
|
99
|
+
Then serve with:
|
|
100
|
+
xania serve .
|
|
101
|
+
\"\"\"
|
|
102
|
+
|
|
103
|
+
from pathlib import Path
|
|
104
|
+
from xania.runtime.spa import SpaApp, StaticPage, TemplatePage
|
|
105
|
+
|
|
106
|
+
# Create your SPA
|
|
107
|
+
app = SpaApp(
|
|
108
|
+
name="MySite",
|
|
109
|
+
root_id="app",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Add pages
|
|
113
|
+
app.route(
|
|
114
|
+
"/",
|
|
115
|
+
StaticPage(
|
|
116
|
+
title="Home",
|
|
117
|
+
html='''
|
|
118
|
+
<div class="min-h-screen bg-gradient-to-br from-slate-950 to-slate-900 text-white">
|
|
119
|
+
<div class="max-w-4xl mx-auto px-4 py-20 text-center">
|
|
120
|
+
<h1 class="text-5xl font-black mb-4">Welcome to Xania</h1>
|
|
121
|
+
<p class="text-xl text-slate-400 mb-8">A Python UI framework for building SPAs</p>
|
|
122
|
+
<nav class="flex gap-4 justify-center">
|
|
123
|
+
<a href="/" class="px-6 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition">Home</a>
|
|
124
|
+
<a href="/about" class="px-6 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg transition">About</a>
|
|
125
|
+
</nav>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
'''
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
app.route(
|
|
133
|
+
"/about",
|
|
134
|
+
StaticPage(
|
|
135
|
+
title="About",
|
|
136
|
+
html='''
|
|
137
|
+
<div class="min-h-screen bg-gradient-to-br from-slate-950 to-slate-900 text-white">
|
|
138
|
+
<div class="max-w-4xl mx-auto px-4 py-20">
|
|
139
|
+
<h1 class="text-5xl font-black mb-8">About</h1>
|
|
140
|
+
<p class="text-lg text-slate-300 mb-4">This is a Xania website!</p>
|
|
141
|
+
<p class="text-lg text-slate-300 mb-8">Edit this file and recompile to make changes.</p>
|
|
142
|
+
<a href="/" class="px-6 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition">← Back to Home</a>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
'''
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
from xania.runtime.compiler import SpaCompiler
|
|
151
|
+
|
|
152
|
+
# Compile to static files in current directory
|
|
153
|
+
compiler = SpaCompiler(title="My Website", tailwind=True)
|
|
154
|
+
compiler.write(app, Path.cwd())
|
|
155
|
+
print("✅ Website compiled! Run: xania serve .")
|
|
156
|
+
""", encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
# Create README
|
|
159
|
+
readme = project_path / "README.md"
|
|
160
|
+
readme.write_text(f"""# {project_name.title()}
|
|
161
|
+
|
|
162
|
+
A Xania website.
|
|
163
|
+
|
|
164
|
+
## Quick Start
|
|
165
|
+
|
|
166
|
+
1. **Compile your website:**
|
|
167
|
+
```bash
|
|
168
|
+
python app.py
|
|
169
|
+
```
|
|
170
|
+
This generates:
|
|
171
|
+
- `index.html`
|
|
172
|
+
- `static/app.js`
|
|
173
|
+
- `static/spa_runtime.js`
|
|
174
|
+
|
|
175
|
+
2. **Run the server:**
|
|
176
|
+
```bash
|
|
177
|
+
xania serve .
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Then open: http://127.0.0.1:8000
|
|
181
|
+
|
|
182
|
+
## Edit Your Site
|
|
183
|
+
|
|
184
|
+
- Edit `app.py` to add pages and change content
|
|
185
|
+
- Run `python app.py` to recompile
|
|
186
|
+
- Refresh browser to see changes
|
|
187
|
+
|
|
188
|
+
## Deploy
|
|
189
|
+
|
|
190
|
+
Copy the generated files (`index.html` + `static/`) to any static hosting:
|
|
191
|
+
- Netlify
|
|
192
|
+
- Vercel
|
|
193
|
+
- GitHub Pages
|
|
194
|
+
- AWS S3
|
|
195
|
+
- Any web server
|
|
196
|
+
|
|
197
|
+
## Learn More
|
|
198
|
+
|
|
199
|
+
Visit: https://github.com/xania/framework
|
|
200
|
+
""", encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
click.echo(f"✅ Created project: {project_name}/")
|
|
203
|
+
click.echo("")
|
|
204
|
+
click.echo("Next steps:")
|
|
205
|
+
click.echo(f" cd {project_name}")
|
|
206
|
+
click.echo(" python app.py # Compile")
|
|
207
|
+
click.echo(" xania serve . # Run server")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@cli.command("dev")
|
|
211
|
+
@click.option("--app", "app_path", default=None, help="ASGI app path, e.g. module:app")
|
|
212
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
213
|
+
@click.option("--port", default=8000, show_default=True, type=int)
|
|
214
|
+
@click.option("--reload/--no-reload", default=True, show_default=True)
|
|
215
|
+
def dev(app_path: Optional[str], host: str, port: int, reload: bool) -> None:
|
|
216
|
+
"""Run a development server (uvicorn --reload by default)."""
|
|
217
|
+
cfg = CliConfig()
|
|
218
|
+
_run_uvicorn(app_path or cfg.default_app, host=host, port=port, reload=reload)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command("build")
|
|
222
|
+
@click.option("--out", default="dist", show_default=True, type=click.Path(path_type=Path))
|
|
223
|
+
@click.option("--title", default="Xania App", show_default=True)
|
|
224
|
+
@click.option("--tailwind/--no-tailwind", default=True, show_default=True)
|
|
225
|
+
def build(out: Path, title: str, tailwind: bool) -> None:
|
|
226
|
+
"""Write `index.html` + `static/` for static hosting."""
|
|
227
|
+
out = out.resolve()
|
|
228
|
+
_copy_package_static(out)
|
|
229
|
+
_write_spa_index_html(out / "index.html", title=title, tailwind=tailwind)
|
|
230
|
+
click.echo(f"Wrote static site to {out}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@cli.command("serve")
|
|
234
|
+
@click.argument("spa_dir", type=click.Path(exists=True, path_type=Path), required=False, default=Path.cwd())
|
|
235
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
236
|
+
@click.option("--port", default=8000, show_default=True, type=int)
|
|
237
|
+
def serve(spa_dir: Path, host: str, port: int) -> None:
|
|
238
|
+
"""Serve a compiled Xania SPA on a FastAPI server.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
xania serve ./dist
|
|
242
|
+
xania serve ./documentation --port 9000
|
|
243
|
+
"""
|
|
244
|
+
spa_path = spa_dir.resolve()
|
|
245
|
+
index_html = spa_path / "index.html"
|
|
246
|
+
static_dir = spa_path / "static"
|
|
247
|
+
|
|
248
|
+
if not index_html.exists():
|
|
249
|
+
raise click.ClickException(f"index.html not found in {spa_path}")
|
|
250
|
+
if not static_dir.exists():
|
|
251
|
+
raise click.ClickException(f"static/ directory not found in {spa_path}")
|
|
252
|
+
|
|
253
|
+
# Create a minimal FastAPI app that serves the SPA
|
|
254
|
+
try:
|
|
255
|
+
import uvicorn
|
|
256
|
+
from fastapi import FastAPI
|
|
257
|
+
from fastapi.staticfiles import StaticFiles
|
|
258
|
+
from fastapi.responses import HTMLResponse
|
|
259
|
+
|
|
260
|
+
app = FastAPI()
|
|
261
|
+
|
|
262
|
+
# Mount static files
|
|
263
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
264
|
+
|
|
265
|
+
# Serve index.html with history API fallback
|
|
266
|
+
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
|
267
|
+
def spa_fallback(full_path: str) -> str:
|
|
268
|
+
# Skip static assets
|
|
269
|
+
if full_path.startswith("static/"):
|
|
270
|
+
return "Not Found"
|
|
271
|
+
return index_html.read_text(encoding="utf-8")
|
|
272
|
+
|
|
273
|
+
@app.get("/", response_class=HTMLResponse)
|
|
274
|
+
def spa_root() -> str:
|
|
275
|
+
return index_html.read_text(encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
click.echo(f"🚀 Serving SPA from {spa_path}")
|
|
278
|
+
click.echo(f"📍 http://{host}:{port}")
|
|
279
|
+
uvicorn.run(app, host=host, port=port)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
raise click.ClickException(f"Failed to start server: {e}") from e
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@cli.command("help")
|
|
285
|
+
def help_cmd() -> None:
|
|
286
|
+
"""Print a quick-start guide."""
|
|
287
|
+
click.echo(
|
|
288
|
+
"\n".join(
|
|
289
|
+
[
|
|
290
|
+
"╔════════════════════════════════════════╗",
|
|
291
|
+
"║ Xania — Python UI Framework ║",
|
|
292
|
+
"╚════════════════════════════════════════╝",
|
|
293
|
+
"",
|
|
294
|
+
"📦 Creating a new website:",
|
|
295
|
+
" xania init my_website",
|
|
296
|
+
" cd my_website",
|
|
297
|
+
" python app.py # Compile to static files",
|
|
298
|
+
" xania serve . # Run on http://127.0.0.1:8000",
|
|
299
|
+
"",
|
|
300
|
+
"🚀 Running an existing website:",
|
|
301
|
+
" xania serve ./my_website",
|
|
302
|
+
"",
|
|
303
|
+
"🛠️ Development:",
|
|
304
|
+
" xania dev # Run FastAPI dev server",
|
|
305
|
+
" xania dev --app yourproj.web:app # Custom app",
|
|
306
|
+
"",
|
|
307
|
+
"📦 Building (for static hosting):",
|
|
308
|
+
" xania build --out dist # Generate static files",
|
|
309
|
+
"",
|
|
310
|
+
"💡 Workflow:",
|
|
311
|
+
" 1. Create project: xania init my_site",
|
|
312
|
+
" 2. Edit app.py with your pages",
|
|
313
|
+
" 3. Compile: python app.py",
|
|
314
|
+
" 4. Run: xania serve .",
|
|
315
|
+
" 5. Deploy: Copy static files to hosting",
|
|
316
|
+
"",
|
|
317
|
+
"📚 Examples:",
|
|
318
|
+
" - See documentation/ for a full tutorial SPA",
|
|
319
|
+
" - Run: xania serve documentation",
|
|
320
|
+
]
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: list[str] | None = None) -> None: # pragma: no cover
|
|
326
|
+
cli.main(args=argv, prog_name="xania")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
__all__ = ["cli", "main"]
|
|
330
|
+
|
xania/engine/runtime.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from xania.renderer.component import Component
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RuntimeConfig:
|
|
11
|
+
title: str = "App"
|
|
12
|
+
tailwind: bool = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def html_shell(components: Iterable[tuple[str, Component]], config: RuntimeConfig | None = None) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Return a full HTML document with component mount points.
|
|
18
|
+
|
|
19
|
+
- No inline JS.
|
|
20
|
+
- The runtime is served from `/static/runtime.js`.
|
|
21
|
+
- Each mount point is a single div with stable id + data-component name.
|
|
22
|
+
"""
|
|
23
|
+
cfg = config or RuntimeConfig()
|
|
24
|
+
|
|
25
|
+
mounts = "\n".join(
|
|
26
|
+
f' <div id="{c.id}" data-component="{name}"></div>' for name, c in components
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
tw = (
|
|
30
|
+
' <script src="https://cdn.tailwindcss.com"></script>\n'
|
|
31
|
+
if cfg.tailwind
|
|
32
|
+
else ""
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return f"""<!DOCTYPE html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="UTF-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
40
|
+
<title>{cfg.title}</title>
|
|
41
|
+
{tw} </head>
|
|
42
|
+
<body>
|
|
43
|
+
{mounts}
|
|
44
|
+
<script>window.XaniaConfig={{serverEvents:true}};</script>
|
|
45
|
+
<script src="/static/runtime.js?v=2"></script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["RuntimeConfig", "html_shell"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from html import escape
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from xania.renderer.elements import Element, VoidElement
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _escape_attr(value: Any) -> str:
|
|
10
|
+
# Escape for HTML attributes surrounded with double quotes.
|
|
11
|
+
return escape(str(value), quote=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize_attr_name(name: str) -> str:
|
|
15
|
+
if name == "class_name":
|
|
16
|
+
return "class"
|
|
17
|
+
if name == "for_":
|
|
18
|
+
return "for"
|
|
19
|
+
if name == "http_equiv":
|
|
20
|
+
return "http-equiv"
|
|
21
|
+
return name.replace("_", "-")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def serialize(node: Element | str | None) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Convert VDOM nodes into HTML.
|
|
27
|
+
|
|
28
|
+
- Escapes text nodes
|
|
29
|
+
- Escapes attribute values
|
|
30
|
+
- Normalizes python-friendly attribute names (class_name -> class, etc.)
|
|
31
|
+
"""
|
|
32
|
+
if node is None:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
if isinstance(node, str):
|
|
36
|
+
return escape(node)
|
|
37
|
+
|
|
38
|
+
if isinstance(node, VoidElement):
|
|
39
|
+
attrs = _serialize_attrs(node.attrs)
|
|
40
|
+
return f"<{node.tag}{attrs} />"
|
|
41
|
+
|
|
42
|
+
attrs = _serialize_attrs(node.attrs)
|
|
43
|
+
children_html = "".join(serialize(c if isinstance(c, (Element, str)) else str(c)) for c in node.children if c is not None)
|
|
44
|
+
return f"<{node.tag}{attrs}>{children_html}</{node.tag}>"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _serialize_attrs(attrs: dict[str, Any]) -> str:
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
for k, v in attrs.items():
|
|
50
|
+
name = _normalize_attr_name(k)
|
|
51
|
+
if v is None or v is False:
|
|
52
|
+
continue
|
|
53
|
+
if v is True:
|
|
54
|
+
parts.append(name)
|
|
55
|
+
continue
|
|
56
|
+
parts.append(f'{name}="{_escape_attr(v)}"')
|
|
57
|
+
return (" " + " ".join(parts)) if parts else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["serialize"]
|
xania/example/counter.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xania.renderer.component import Component
|
|
6
|
+
from xania.renderer.elements import Button, Div, H1, Span
|
|
7
|
+
from xania.renderer.state import State
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Counter(Component):
|
|
11
|
+
def initial_state(self) -> dict[str, Any]:
|
|
12
|
+
return {"count": 0}
|
|
13
|
+
|
|
14
|
+
def render(self, state: State):
|
|
15
|
+
count = int(state.count)
|
|
16
|
+
color = (
|
|
17
|
+
"text-green-400"
|
|
18
|
+
if count > 0
|
|
19
|
+
else "text-red-400"
|
|
20
|
+
if count < 0
|
|
21
|
+
else "text-white"
|
|
22
|
+
)
|
|
23
|
+
return Div(
|
|
24
|
+
H1("Counter", class_name="text-2xl font-black text-white mb-6"),
|
|
25
|
+
Span(str(count), class_name=f"text-6xl font-black {color} block mb-8"),
|
|
26
|
+
Div(
|
|
27
|
+
Button("−", class_name="bg-red-600 text-white px-6 py-3 rounded-xl text-xl font-bold cursor-pointer border-0 hover:bg-red-500",
|
|
28
|
+
onclick="App.dispatch('Counter','decrement')"),
|
|
29
|
+
Button("Reset", class_name="bg-gray-700 text-white px-6 py-3 rounded-xl text-xl font-bold cursor-pointer border-0",
|
|
30
|
+
onclick="App.dispatch('Counter','reset')"),
|
|
31
|
+
Button("+", class_name="bg-green-600 text-white px-6 py-3 rounded-xl text-xl font-bold cursor-pointer border-0 hover:bg-green-500",
|
|
32
|
+
onclick="App.dispatch('Counter','increment')"),
|
|
33
|
+
class_name="flex gap-4 justify-center"
|
|
34
|
+
),
|
|
35
|
+
class_name="flex flex-col items-center justify-center min-h-screen bg-gray-950"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def on_increment(self, state: State, payload: dict[str, Any]) -> None:
|
|
39
|
+
state.count += 1
|
|
40
|
+
|
|
41
|
+
def on_decrement(self, state: State, payload: dict[str, Any]) -> None:
|
|
42
|
+
state.count -= 1
|
|
43
|
+
|
|
44
|
+
def on_reset(self, state: State, payload: dict[str, Any]) -> None:
|
|
45
|
+
state.count = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["Counter"]
|
|
49
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from xania.renderer.elements import Element
|
|
7
|
+
from xania.renderer.render import render as render_html
|
|
8
|
+
from xania.renderer.state import State, useRef, useState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Component:
|
|
13
|
+
"""
|
|
14
|
+
Production-oriented base component.
|
|
15
|
+
|
|
16
|
+
- `id`: stable DOM container id (no random ids scattered)
|
|
17
|
+
- `state`: persisted server-side (per component instance; can be replaced with session storage later)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
state: State = field(default_factory=State)
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if not self.id:
|
|
25
|
+
raise ValueError("Component.id must be non-empty")
|
|
26
|
+
# Initialize state only once.
|
|
27
|
+
if not self.state._data: # type: ignore[attr-defined]
|
|
28
|
+
self.state = State(**self.initial_state())
|
|
29
|
+
|
|
30
|
+
def initial_state(self) -> dict[str, Any]:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
def render(self, state: State) -> Element | str:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def handle(self, action: str, payload: dict[str, Any]) -> None:
|
|
37
|
+
handler = getattr(self, f"on_{action}", None)
|
|
38
|
+
if handler:
|
|
39
|
+
handler(self.state, payload)
|
|
40
|
+
|
|
41
|
+
def to_html(self) -> str:
|
|
42
|
+
self.state.reset_hooks()
|
|
43
|
+
return render_html(self.render(self.state))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["Component", "useState", "useRef"]
|