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.
Files changed (49) hide show
  1. nextpy/__init__.py +50 -0
  2. nextpy/auth.py +94 -0
  3. nextpy/builder.py +123 -0
  4. nextpy/cli.py +490 -0
  5. nextpy/components/__init__.py +45 -0
  6. nextpy/components/feedback.py +210 -0
  7. nextpy/components/form.py +346 -0
  8. nextpy/components/head.py +167 -0
  9. nextpy/components/hooks_provider.py +64 -0
  10. nextpy/components/image.py +180 -0
  11. nextpy/components/layout.py +206 -0
  12. nextpy/components/link.py +132 -0
  13. nextpy/components/loader.py +65 -0
  14. nextpy/components/toast.py +101 -0
  15. nextpy/components/visual.py +185 -0
  16. nextpy/config.py +75 -0
  17. nextpy/core/__init__.py +21 -0
  18. nextpy/core/builder.py +237 -0
  19. nextpy/core/data_fetching.py +221 -0
  20. nextpy/core/renderer.py +252 -0
  21. nextpy/core/router.py +233 -0
  22. nextpy/core/sync.py +34 -0
  23. nextpy/db.py +121 -0
  24. nextpy/dev_server.py +69 -0
  25. nextpy/dev_tools.py +157 -0
  26. nextpy/errors.py +70 -0
  27. nextpy/hooks.py +348 -0
  28. nextpy/performance.py +78 -0
  29. nextpy/plugins.py +61 -0
  30. nextpy/py.typed +0 -0
  31. nextpy/server/__init__.py +6 -0
  32. nextpy/server/app.py +325 -0
  33. nextpy/server/debug.py +93 -0
  34. nextpy/server/middleware.py +88 -0
  35. nextpy/utils/__init__.py +0 -0
  36. nextpy/utils/cache.py +89 -0
  37. nextpy/utils/email.py +59 -0
  38. nextpy/utils/file_upload.py +65 -0
  39. nextpy/utils/logging.py +52 -0
  40. nextpy/utils/search.py +59 -0
  41. nextpy/utils/seo.py +85 -0
  42. nextpy/utils/validators.py +58 -0
  43. nextpy/websocket.py +76 -0
  44. nextpy_framework-1.0.0.dist-info/METADATA +343 -0
  45. nextpy_framework-1.0.0.dist-info/RECORD +49 -0
  46. nextpy_framework-1.0.0.dist-info/WHEEL +5 -0
  47. nextpy_framework-1.0.0.dist-info/entry_points.txt +2 -0
  48. nextpy_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
  49. 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}")
@@ -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