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 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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from xania.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
8
+
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
+
@@ -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"]
@@ -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,12 @@
1
+ from .component import Component
2
+ from .registry import ComponentRegistry
3
+ from .state import State, useRef, useState
4
+
5
+ __all__ = [
6
+ "Component",
7
+ "ComponentRegistry",
8
+ "State",
9
+ "useState",
10
+ "useRef",
11
+ ]
12
+
@@ -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"]