nextpy-framework 1.0.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.
- nextpy/__init__.py +50 -0
- nextpy/auth.py +94 -0
- nextpy/builder.py +123 -0
- nextpy/cli.py +490 -0
- nextpy/components/__init__.py +45 -0
- nextpy/components/feedback.py +210 -0
- nextpy/components/form.py +346 -0
- nextpy/components/head.py +167 -0
- nextpy/components/hooks_provider.py +64 -0
- nextpy/components/image.py +180 -0
- nextpy/components/layout.py +206 -0
- nextpy/components/link.py +132 -0
- nextpy/components/loader.py +65 -0
- nextpy/components/toast.py +101 -0
- nextpy/components/visual.py +185 -0
- nextpy/config.py +75 -0
- nextpy/core/__init__.py +21 -0
- nextpy/core/builder.py +237 -0
- nextpy/core/data_fetching.py +221 -0
- nextpy/core/renderer.py +252 -0
- nextpy/core/router.py +233 -0
- nextpy/core/sync.py +34 -0
- nextpy/db.py +121 -0
- nextpy/dev_server.py +69 -0
- nextpy/dev_tools.py +157 -0
- nextpy/errors.py +70 -0
- nextpy/hooks.py +348 -0
- nextpy/performance.py +78 -0
- nextpy/plugins.py +61 -0
- nextpy/py.typed +0 -0
- nextpy/server/__init__.py +6 -0
- nextpy/server/app.py +325 -0
- nextpy/server/debug.py +93 -0
- nextpy/server/middleware.py +88 -0
- nextpy/utils/__init__.py +0 -0
- nextpy/utils/cache.py +89 -0
- nextpy/utils/email.py +59 -0
- nextpy/utils/file_upload.py +65 -0
- nextpy/utils/logging.py +52 -0
- nextpy/utils/search.py +59 -0
- nextpy/utils/seo.py +85 -0
- nextpy/utils/validators.py +58 -0
- nextpy/websocket.py +76 -0
- nextpy_framework-1.0.0.dist-info/METADATA +343 -0
- nextpy_framework-1.0.0.dist-info/RECORD +49 -0
- nextpy_framework-1.0.0.dist-info/WHEEL +5 -0
- nextpy_framework-1.0.0.dist-info/entry_points.txt +2 -0
- nextpy_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
- nextpy_framework-1.0.0.dist-info/top_level.txt +1 -0
nextpy/core/builder.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Builder - Static Site Generation (SSG)
|
|
3
|
+
Builds static HTML files to the out/ directory
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from nextpy.core.router import Router, Route
|
|
14
|
+
from nextpy.core.renderer import Renderer
|
|
15
|
+
from nextpy.core.data_fetching import (
|
|
16
|
+
PageContext,
|
|
17
|
+
PropsResult,
|
|
18
|
+
execute_data_fetching,
|
|
19
|
+
get_static_paths_for_route,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Builder:
|
|
24
|
+
"""
|
|
25
|
+
Static Site Generator for NextPy
|
|
26
|
+
Builds all pages to static HTML in the out/ directory
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
pages_dir: str = "pages",
|
|
32
|
+
templates_dir: str = "templates",
|
|
33
|
+
public_dir: str = "public",
|
|
34
|
+
out_dir: str = "out",
|
|
35
|
+
):
|
|
36
|
+
self.pages_dir = Path(pages_dir)
|
|
37
|
+
self.templates_dir = Path(templates_dir)
|
|
38
|
+
self.public_dir = Path(public_dir)
|
|
39
|
+
self.out_dir = Path(out_dir)
|
|
40
|
+
|
|
41
|
+
self.router = Router(pages_dir, templates_dir)
|
|
42
|
+
self.renderer = Renderer(templates_dir, pages_dir, public_dir)
|
|
43
|
+
|
|
44
|
+
self.build_manifest: Dict[str, Any] = {
|
|
45
|
+
"version": 1,
|
|
46
|
+
"pages": {},
|
|
47
|
+
"build_time": None,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async def build(self, clean: bool = True) -> Dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Build all pages to static HTML
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
clean: Whether to clean the output directory first
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Build manifest with information about built pages
|
|
59
|
+
"""
|
|
60
|
+
print("NextPy Build Starting...")
|
|
61
|
+
start_time = datetime.now()
|
|
62
|
+
|
|
63
|
+
if clean and self.out_dir.exists():
|
|
64
|
+
shutil.rmtree(self.out_dir)
|
|
65
|
+
|
|
66
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
self._copy_public_files()
|
|
69
|
+
|
|
70
|
+
self.router.scan_pages()
|
|
71
|
+
|
|
72
|
+
await self._build_static_routes()
|
|
73
|
+
|
|
74
|
+
await self._build_dynamic_routes()
|
|
75
|
+
|
|
76
|
+
self._create_sitemap()
|
|
77
|
+
|
|
78
|
+
self.build_manifest["build_time"] = datetime.now().isoformat()
|
|
79
|
+
self._write_manifest()
|
|
80
|
+
|
|
81
|
+
duration = (datetime.now() - start_time).total_seconds()
|
|
82
|
+
print(f"\nBuild completed in {duration:.2f}s")
|
|
83
|
+
print(f"Output directory: {self.out_dir}")
|
|
84
|
+
|
|
85
|
+
return self.build_manifest
|
|
86
|
+
|
|
87
|
+
def _copy_public_files(self) -> None:
|
|
88
|
+
"""Copy files from public/ to out/"""
|
|
89
|
+
if not self.public_dir.exists():
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
for item in self.public_dir.rglob("*"):
|
|
93
|
+
if item.is_file():
|
|
94
|
+
relative = item.relative_to(self.public_dir)
|
|
95
|
+
dest = self.out_dir / relative
|
|
96
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
shutil.copy2(item, dest)
|
|
98
|
+
|
|
99
|
+
print(f"Copied public files to {self.out_dir}")
|
|
100
|
+
|
|
101
|
+
async def _build_static_routes(self) -> None:
|
|
102
|
+
"""Build all static (non-dynamic) routes"""
|
|
103
|
+
static_routes = self.router.get_static_routes()
|
|
104
|
+
|
|
105
|
+
for route in static_routes:
|
|
106
|
+
if route.is_api:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
await self._build_page(route)
|
|
110
|
+
|
|
111
|
+
async def _build_dynamic_routes(self) -> None:
|
|
112
|
+
"""Build dynamic routes using getStaticPaths"""
|
|
113
|
+
dynamic_routes = [r for r in self.router.routes if r.is_dynamic]
|
|
114
|
+
|
|
115
|
+
for route in dynamic_routes:
|
|
116
|
+
if route.is_api:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if route.handler:
|
|
120
|
+
module = self._get_module_from_handler(route.handler)
|
|
121
|
+
if module:
|
|
122
|
+
paths_result = await get_static_paths_for_route(module)
|
|
123
|
+
|
|
124
|
+
for path_config in paths_result.paths:
|
|
125
|
+
params = path_config.get("params", path_config)
|
|
126
|
+
await self._build_page(route, params)
|
|
127
|
+
|
|
128
|
+
async def _build_page(
|
|
129
|
+
self,
|
|
130
|
+
route: Route,
|
|
131
|
+
params: Optional[Dict[str, str]] = None
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Build a single page to static HTML"""
|
|
134
|
+
params = params or {}
|
|
135
|
+
|
|
136
|
+
path = self._resolve_path(route.path, params)
|
|
137
|
+
|
|
138
|
+
context = PageContext(
|
|
139
|
+
params=params,
|
|
140
|
+
query={},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
props = {}
|
|
145
|
+
if route.handler:
|
|
146
|
+
module = self._get_module_from_handler(route.handler)
|
|
147
|
+
if module:
|
|
148
|
+
props = await execute_data_fetching(module, context)
|
|
149
|
+
|
|
150
|
+
template_name = self._get_template_for_route(route)
|
|
151
|
+
|
|
152
|
+
html = await self.renderer.render_async(
|
|
153
|
+
template_name,
|
|
154
|
+
context={**props, "params": params},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
output_path = self._get_output_path(path)
|
|
158
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
output_path.write_text(html)
|
|
160
|
+
|
|
161
|
+
self.build_manifest["pages"][path] = {
|
|
162
|
+
"file": str(output_path),
|
|
163
|
+
"params": params,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
print(f" Built: {path} -> {output_path}")
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print(f" Error building {path}: {e}")
|
|
170
|
+
|
|
171
|
+
def _resolve_path(self, route_path: str, params: Dict[str, str]) -> str:
|
|
172
|
+
"""Resolve a dynamic route path with actual params"""
|
|
173
|
+
path = route_path
|
|
174
|
+
for key, value in params.items():
|
|
175
|
+
path = path.replace(f"(?P<{key}>[^/]+)", value)
|
|
176
|
+
path = path.replace(f"(?P<{key}>.+)", value)
|
|
177
|
+
return path
|
|
178
|
+
|
|
179
|
+
def _get_template_for_route(self, route: Route) -> str:
|
|
180
|
+
"""Get the template name for a route"""
|
|
181
|
+
relative = route.file_path.relative_to(self.pages_dir)
|
|
182
|
+
template_name = str(relative).replace(".py", ".html")
|
|
183
|
+
|
|
184
|
+
template_path = self.templates_dir / template_name
|
|
185
|
+
if template_path.exists():
|
|
186
|
+
return template_name
|
|
187
|
+
|
|
188
|
+
return "_page.html"
|
|
189
|
+
|
|
190
|
+
def _get_output_path(self, path: str) -> Path:
|
|
191
|
+
"""Get the output file path for a URL path"""
|
|
192
|
+
if path == "/":
|
|
193
|
+
return self.out_dir / "index.html"
|
|
194
|
+
|
|
195
|
+
clean_path = path.strip("/")
|
|
196
|
+
return self.out_dir / clean_path / "index.html"
|
|
197
|
+
|
|
198
|
+
def _get_module_from_handler(self, handler: Any) -> Optional[Any]:
|
|
199
|
+
"""Get the module that contains the handler"""
|
|
200
|
+
if hasattr(handler, "__module__"):
|
|
201
|
+
import sys
|
|
202
|
+
return sys.modules.get(handler.__module__)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def _create_sitemap(self) -> None:
|
|
206
|
+
"""Generate sitemap.xml"""
|
|
207
|
+
pages = self.build_manifest["pages"]
|
|
208
|
+
|
|
209
|
+
sitemap_lines = [
|
|
210
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
211
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
for path in pages:
|
|
215
|
+
sitemap_lines.append(f" <url>")
|
|
216
|
+
sitemap_lines.append(f" <loc>{path}</loc>")
|
|
217
|
+
sitemap_lines.append(f" <lastmod>{datetime.now().strftime('%Y-%m-%d')}</lastmod>")
|
|
218
|
+
sitemap_lines.append(f" </url>")
|
|
219
|
+
|
|
220
|
+
sitemap_lines.append("</urlset>")
|
|
221
|
+
|
|
222
|
+
sitemap_path = self.out_dir / "sitemap.xml"
|
|
223
|
+
sitemap_path.write_text("\n".join(sitemap_lines))
|
|
224
|
+
print(f" Generated: sitemap.xml")
|
|
225
|
+
|
|
226
|
+
def _write_manifest(self) -> None:
|
|
227
|
+
"""Write the build manifest to the output directory"""
|
|
228
|
+
import json
|
|
229
|
+
manifest_path = self.out_dir / "_nextpy" / "build-manifest.json"
|
|
230
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
manifest_path.write_text(json.dumps(self.build_manifest, indent=2))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def build_project(**kwargs) -> Dict[str, Any]:
|
|
235
|
+
"""Convenience function to build the project"""
|
|
236
|
+
builder = Builder(**kwargs)
|
|
237
|
+
return await builder.build()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Data Fetching - Server-side data fetching inspired by Next.js
|
|
3
|
+
Implements:
|
|
4
|
+
- getServerSideProps (SSR) - Fetch data on every request
|
|
5
|
+
- getStaticProps (SSG) - Fetch data at build time
|
|
6
|
+
- getStaticPaths - Generate dynamic routes at build time
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import functools
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PropsResult(BaseModel):
|
|
17
|
+
"""Result from getServerSideProps or getStaticProps"""
|
|
18
|
+
props: Dict[str, Any] = {}
|
|
19
|
+
redirect: Optional[Dict[str, str]] = None
|
|
20
|
+
not_found: bool = False
|
|
21
|
+
revalidate: Optional[int] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StaticPathsResult(BaseModel):
|
|
25
|
+
"""Result from getStaticPaths"""
|
|
26
|
+
paths: List[Dict[str, Any]] = []
|
|
27
|
+
fallback: Union[bool, str] = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PageContext:
|
|
32
|
+
"""Context passed to data fetching functions"""
|
|
33
|
+
params: Dict[str, str]
|
|
34
|
+
query: Dict[str, str]
|
|
35
|
+
req: Optional[Any] = None
|
|
36
|
+
res: Optional[Any] = None
|
|
37
|
+
preview: bool = False
|
|
38
|
+
preview_data: Optional[Dict[str, Any]] = None
|
|
39
|
+
locale: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T", bound=Callable)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_server_side_props(func: T) -> T:
|
|
46
|
+
"""
|
|
47
|
+
Decorator to mark a function as getServerSideProps
|
|
48
|
+
This function will be called on every request
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
@get_server_side_props
|
|
52
|
+
async def get_data(context: PageContext) -> PropsResult:
|
|
53
|
+
data = await fetch_from_api()
|
|
54
|
+
return PropsResult(props={"data": data})
|
|
55
|
+
"""
|
|
56
|
+
func._is_server_side_props = True
|
|
57
|
+
func._data_fetching_type = "ssr"
|
|
58
|
+
|
|
59
|
+
@functools.wraps(func)
|
|
60
|
+
async def wrapper(context: PageContext) -> PropsResult:
|
|
61
|
+
if asyncio.iscoroutinefunction(func):
|
|
62
|
+
result = await func(context)
|
|
63
|
+
else:
|
|
64
|
+
result = func(context)
|
|
65
|
+
|
|
66
|
+
if isinstance(result, dict):
|
|
67
|
+
result = PropsResult(**result)
|
|
68
|
+
elif not isinstance(result, PropsResult):
|
|
69
|
+
result = PropsResult(props={"data": result})
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
wrapper._is_server_side_props = True
|
|
74
|
+
wrapper._data_fetching_type = "ssr"
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_static_props(func: T) -> T:
|
|
79
|
+
"""
|
|
80
|
+
Decorator to mark a function as getStaticProps
|
|
81
|
+
This function will be called at build time (SSG)
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
@get_static_props
|
|
85
|
+
async def get_data(context: PageContext) -> PropsResult:
|
|
86
|
+
data = await fetch_from_cms()
|
|
87
|
+
return PropsResult(
|
|
88
|
+
props={"data": data},
|
|
89
|
+
revalidate=60 # ISR: regenerate every 60 seconds
|
|
90
|
+
)
|
|
91
|
+
"""
|
|
92
|
+
func._is_static_props = True
|
|
93
|
+
func._data_fetching_type = "ssg"
|
|
94
|
+
|
|
95
|
+
@functools.wraps(func)
|
|
96
|
+
async def wrapper(context: PageContext) -> PropsResult:
|
|
97
|
+
if asyncio.iscoroutinefunction(func):
|
|
98
|
+
result = await func(context)
|
|
99
|
+
else:
|
|
100
|
+
result = func(context)
|
|
101
|
+
|
|
102
|
+
if isinstance(result, dict):
|
|
103
|
+
result = PropsResult(**result)
|
|
104
|
+
elif not isinstance(result, PropsResult):
|
|
105
|
+
result = PropsResult(props={"data": result})
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
wrapper._is_static_props = True
|
|
110
|
+
wrapper._data_fetching_type = "ssg"
|
|
111
|
+
return wrapper
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_static_paths(func: T) -> T:
|
|
115
|
+
"""
|
|
116
|
+
Decorator to mark a function as getStaticPaths
|
|
117
|
+
Used with dynamic routes to generate paths at build time
|
|
118
|
+
|
|
119
|
+
Usage:
|
|
120
|
+
@get_static_paths
|
|
121
|
+
async def get_paths() -> StaticPathsResult:
|
|
122
|
+
posts = await fetch_all_posts()
|
|
123
|
+
return StaticPathsResult(
|
|
124
|
+
paths=[{"params": {"slug": p.slug}} for p in posts],
|
|
125
|
+
fallback=False
|
|
126
|
+
)
|
|
127
|
+
"""
|
|
128
|
+
func._is_static_paths = True
|
|
129
|
+
|
|
130
|
+
@functools.wraps(func)
|
|
131
|
+
async def wrapper() -> StaticPathsResult:
|
|
132
|
+
if asyncio.iscoroutinefunction(func):
|
|
133
|
+
result = await func()
|
|
134
|
+
else:
|
|
135
|
+
result = func()
|
|
136
|
+
|
|
137
|
+
if isinstance(result, dict):
|
|
138
|
+
result = StaticPathsResult(**result)
|
|
139
|
+
elif isinstance(result, list):
|
|
140
|
+
result = StaticPathsResult(paths=result)
|
|
141
|
+
elif not isinstance(result, StaticPathsResult):
|
|
142
|
+
result = StaticPathsResult(paths=[])
|
|
143
|
+
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
wrapper._is_static_paths = True
|
|
147
|
+
return wrapper
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def execute_data_fetching(
|
|
151
|
+
module: Any,
|
|
152
|
+
context: PageContext
|
|
153
|
+
) -> Dict[str, Any]:
|
|
154
|
+
"""
|
|
155
|
+
Execute the appropriate data fetching function for a page module
|
|
156
|
+
Returns the props to pass to the template
|
|
157
|
+
"""
|
|
158
|
+
props = {}
|
|
159
|
+
|
|
160
|
+
for name in ["getServerSideProps", "get_server_side_props", "getStaticProps", "get_static_props"]:
|
|
161
|
+
if hasattr(module, name):
|
|
162
|
+
func = getattr(module, name)
|
|
163
|
+
|
|
164
|
+
if asyncio.iscoroutinefunction(func):
|
|
165
|
+
result = await func(context)
|
|
166
|
+
else:
|
|
167
|
+
result = func(context)
|
|
168
|
+
|
|
169
|
+
if isinstance(result, PropsResult):
|
|
170
|
+
if result.not_found:
|
|
171
|
+
raise PageNotFoundError()
|
|
172
|
+
if result.redirect:
|
|
173
|
+
raise RedirectError(
|
|
174
|
+
result.redirect.get("destination", "/"),
|
|
175
|
+
result.redirect.get("permanent", False)
|
|
176
|
+
)
|
|
177
|
+
props.update(result.props)
|
|
178
|
+
elif isinstance(result, dict):
|
|
179
|
+
if "props" in result:
|
|
180
|
+
props.update(result["props"])
|
|
181
|
+
else:
|
|
182
|
+
props.update(result)
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
return props
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def get_static_paths_for_route(module: Any) -> StaticPathsResult:
|
|
189
|
+
"""
|
|
190
|
+
Get static paths for a dynamic route module
|
|
191
|
+
"""
|
|
192
|
+
for name in ["getStaticPaths", "get_static_paths"]:
|
|
193
|
+
if hasattr(module, name):
|
|
194
|
+
func = getattr(module, name)
|
|
195
|
+
|
|
196
|
+
if asyncio.iscoroutinefunction(func):
|
|
197
|
+
result = await func()
|
|
198
|
+
else:
|
|
199
|
+
result = func()
|
|
200
|
+
|
|
201
|
+
if isinstance(result, StaticPathsResult):
|
|
202
|
+
return result
|
|
203
|
+
elif isinstance(result, dict):
|
|
204
|
+
return StaticPathsResult(**result)
|
|
205
|
+
elif isinstance(result, list):
|
|
206
|
+
return StaticPathsResult(paths=result)
|
|
207
|
+
|
|
208
|
+
return StaticPathsResult(paths=[], fallback=False)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PageNotFoundError(Exception):
|
|
212
|
+
"""Raised when a page returns not_found: True"""
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RedirectError(Exception):
|
|
217
|
+
"""Raised when a page returns a redirect"""
|
|
218
|
+
def __init__(self, destination: str, permanent: bool = False):
|
|
219
|
+
self.destination = destination
|
|
220
|
+
self.permanent = permanent
|
|
221
|
+
super().__init__(f"Redirect to {destination}")
|
nextpy/core/renderer.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Renderer - Server-side rendering with Jinja2
|
|
3
|
+
Handles template rendering, layouts, and component composition
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Optional, List
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape, TemplateNotFound
|
|
10
|
+
from markupsafe import Markup
|
|
11
|
+
|
|
12
|
+
from nextpy.components.head import Head
|
|
13
|
+
from nextpy.components.link import Link
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Renderer:
|
|
17
|
+
"""
|
|
18
|
+
Server-side renderer using Jinja2
|
|
19
|
+
Supports layouts, components, and template inheritance
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
templates_dir: str = "templates",
|
|
25
|
+
pages_dir: str = "pages",
|
|
26
|
+
public_dir: str = "public"
|
|
27
|
+
):
|
|
28
|
+
self.templates_dir = Path(templates_dir)
|
|
29
|
+
self.pages_dir = Path(pages_dir)
|
|
30
|
+
self.public_dir = Path(public_dir)
|
|
31
|
+
|
|
32
|
+
loader = FileSystemLoader([
|
|
33
|
+
str(self.templates_dir),
|
|
34
|
+
str(self.pages_dir),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
self.env = Environment(
|
|
38
|
+
loader=loader,
|
|
39
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
40
|
+
enable_async=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self._register_globals()
|
|
44
|
+
self._register_filters()
|
|
45
|
+
|
|
46
|
+
def _register_globals(self) -> None:
|
|
47
|
+
"""Register global functions and components available in templates"""
|
|
48
|
+
self.env.globals["Head"] = Head
|
|
49
|
+
self.env.globals["Link"] = Link
|
|
50
|
+
self.env.globals["Markup"] = Markup
|
|
51
|
+
|
|
52
|
+
self.env.globals["range"] = range
|
|
53
|
+
self.env.globals["len"] = len
|
|
54
|
+
self.env.globals["str"] = str
|
|
55
|
+
self.env.globals["int"] = int
|
|
56
|
+
self.env.globals["list"] = list
|
|
57
|
+
self.env.globals["dict"] = dict
|
|
58
|
+
self.env.globals["enumerate"] = enumerate
|
|
59
|
+
|
|
60
|
+
def _register_filters(self) -> None:
|
|
61
|
+
"""Register custom Jinja2 filters"""
|
|
62
|
+
self.env.filters["json"] = self._json_filter
|
|
63
|
+
self.env.filters["date"] = self._date_filter
|
|
64
|
+
self.env.filters["truncate_words"] = self._truncate_words_filter
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _json_filter(value: Any) -> str:
|
|
68
|
+
"""Convert value to JSON string"""
|
|
69
|
+
import json
|
|
70
|
+
return json.dumps(value)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _date_filter(value: Any, format_str: str = "%Y-%m-%d") -> str:
|
|
74
|
+
"""Format a date value"""
|
|
75
|
+
from datetime import datetime
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
value = datetime.fromisoformat(value)
|
|
78
|
+
if hasattr(value, "strftime"):
|
|
79
|
+
return value.strftime(format_str)
|
|
80
|
+
return str(value)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _truncate_words_filter(value: str, num_words: int = 20) -> str:
|
|
84
|
+
"""Truncate text to a number of words"""
|
|
85
|
+
words = value.split()
|
|
86
|
+
if len(words) <= num_words:
|
|
87
|
+
return value
|
|
88
|
+
return " ".join(words[:num_words]) + "..."
|
|
89
|
+
|
|
90
|
+
def render(
|
|
91
|
+
self,
|
|
92
|
+
template_name: str,
|
|
93
|
+
context: Optional[Dict[str, Any]] = None,
|
|
94
|
+
layout: Optional[str] = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Render a template with the given context
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
template_name: Name of the template file
|
|
101
|
+
context: Dictionary of variables to pass to template
|
|
102
|
+
layout: Optional layout template to wrap the content
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Rendered HTML string
|
|
106
|
+
"""
|
|
107
|
+
context = context or {}
|
|
108
|
+
|
|
109
|
+
context.setdefault("__page__", template_name)
|
|
110
|
+
context.setdefault("__layout__", layout)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
template = self.env.get_template(template_name)
|
|
114
|
+
except TemplateNotFound:
|
|
115
|
+
template_path = self._find_template(template_name)
|
|
116
|
+
if template_path:
|
|
117
|
+
template = self.env.get_template(str(template_path))
|
|
118
|
+
else:
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
content = template.render(**context)
|
|
122
|
+
|
|
123
|
+
if layout:
|
|
124
|
+
try:
|
|
125
|
+
layout_template = self.env.get_template(layout)
|
|
126
|
+
content = layout_template.render(content=Markup(content), **context)
|
|
127
|
+
except TemplateNotFound:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
return content
|
|
131
|
+
|
|
132
|
+
async def render_async(
|
|
133
|
+
self,
|
|
134
|
+
template_name: str,
|
|
135
|
+
context: Optional[Dict[str, Any]] = None,
|
|
136
|
+
layout: Optional[str] = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Async version of render"""
|
|
139
|
+
context = context or {}
|
|
140
|
+
|
|
141
|
+
context.setdefault("__page__", template_name)
|
|
142
|
+
context.setdefault("__layout__", layout)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
template = self.env.get_template(template_name)
|
|
146
|
+
except TemplateNotFound:
|
|
147
|
+
template_path = self._find_template(template_name)
|
|
148
|
+
if template_path:
|
|
149
|
+
template = self.env.get_template(str(template_path))
|
|
150
|
+
else:
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
content = await template.render_async(**context)
|
|
154
|
+
|
|
155
|
+
if layout:
|
|
156
|
+
try:
|
|
157
|
+
layout_template = self.env.get_template(layout)
|
|
158
|
+
content = await layout_template.render_async(
|
|
159
|
+
content=Markup(content),
|
|
160
|
+
**context
|
|
161
|
+
)
|
|
162
|
+
except TemplateNotFound:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
return content
|
|
166
|
+
|
|
167
|
+
def _find_template(self, template_name: str) -> Optional[Path]:
|
|
168
|
+
"""Find a template by searching in multiple locations"""
|
|
169
|
+
search_paths = [
|
|
170
|
+
self.templates_dir / template_name,
|
|
171
|
+
self.templates_dir / f"{template_name}.html",
|
|
172
|
+
self.templates_dir / f"{template_name}.jinja2",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
for path in search_paths:
|
|
176
|
+
if path.exists():
|
|
177
|
+
return path.relative_to(self.templates_dir)
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def render_component(
|
|
182
|
+
self,
|
|
183
|
+
component_func: callable,
|
|
184
|
+
props: Optional[Dict[str, Any]] = None
|
|
185
|
+
) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Render a Python component function
|
|
188
|
+
Components are functions that return HTML strings
|
|
189
|
+
"""
|
|
190
|
+
props = props or {}
|
|
191
|
+
result = component_func(**props)
|
|
192
|
+
|
|
193
|
+
if isinstance(result, str):
|
|
194
|
+
return result
|
|
195
|
+
elif hasattr(result, "__html__"):
|
|
196
|
+
return result.__html__()
|
|
197
|
+
else:
|
|
198
|
+
return str(result)
|
|
199
|
+
|
|
200
|
+
def render_page(
|
|
201
|
+
self,
|
|
202
|
+
page_module: Any,
|
|
203
|
+
context: Optional[Dict[str, Any]] = None,
|
|
204
|
+
params: Optional[Dict[str, str]] = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""
|
|
207
|
+
Render a page module (from pages/ directory)
|
|
208
|
+
|
|
209
|
+
The page module can have:
|
|
210
|
+
- A template() function returning HTML
|
|
211
|
+
- A Page component class
|
|
212
|
+
- A get_template() function returning template name
|
|
213
|
+
"""
|
|
214
|
+
context = context or {}
|
|
215
|
+
params = params or {}
|
|
216
|
+
|
|
217
|
+
context["params"] = params
|
|
218
|
+
|
|
219
|
+
if hasattr(page_module, "template"):
|
|
220
|
+
return page_module.template(**context)
|
|
221
|
+
|
|
222
|
+
if hasattr(page_module, "Page"):
|
|
223
|
+
return self.render_component(page_module.Page, context)
|
|
224
|
+
|
|
225
|
+
if hasattr(page_module, "get_template"):
|
|
226
|
+
template_name = page_module.get_template()
|
|
227
|
+
return self.render(template_name, context)
|
|
228
|
+
|
|
229
|
+
page_name = getattr(page_module, "__name__", "page")
|
|
230
|
+
template_name = f"{page_name}.html"
|
|
231
|
+
return self.render(template_name, context)
|
|
232
|
+
|
|
233
|
+
def get_layout_chain(self, page_path: Path) -> List[str]:
|
|
234
|
+
"""
|
|
235
|
+
Get the chain of layouts for a page
|
|
236
|
+
Similar to Next.js app router layouts
|
|
237
|
+
"""
|
|
238
|
+
layouts = []
|
|
239
|
+
current = page_path.parent
|
|
240
|
+
|
|
241
|
+
while current != self.pages_dir.parent:
|
|
242
|
+
layout_file = current / "_layout.html"
|
|
243
|
+
if layout_file.exists():
|
|
244
|
+
layouts.append(str(layout_file.relative_to(self.templates_dir)))
|
|
245
|
+
current = current.parent
|
|
246
|
+
|
|
247
|
+
layouts.reverse()
|
|
248
|
+
|
|
249
|
+
if (self.templates_dir / "_base.html").exists():
|
|
250
|
+
layouts.insert(0, "_base.html")
|
|
251
|
+
|
|
252
|
+
return layouts
|