monosite 0.0.1__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.
- monosite/__init__.py +7 -0
- monosite/_version.py +34 -0
- monosite/cli/main.py +12 -0
- monosite/core/spa/__init__.py +26 -0
- monosite/core/spa/constants.py +8 -0
- monosite/core/spa/static_files.py +245 -0
- monosite/template/.gitkeep +0 -0
- monosite-0.0.1.dist-info/METADATA +6 -0
- monosite-0.0.1.dist-info/RECORD +11 -0
- monosite-0.0.1.dist-info/WHEEL +4 -0
- monosite-0.0.1.dist-info/entry_points.txt +2 -0
monosite/__init__.py
ADDED
monosite/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
monosite/cli/main.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
app = typer.Typer()
|
|
4
|
+
|
|
5
|
+
@app.command()
|
|
6
|
+
def init(name: str = typer.Argument(..., help="Name of the new project")):
|
|
7
|
+
"""Initialize a new Monosite project."""
|
|
8
|
+
print(f"Initializing new project: {name}")
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Main entry point for the Monosite CLI."""
|
|
12
|
+
app()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from .static_files import SPAStaticFiles
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mount_spa(app: FastAPI, build_dir: str, api_prefix: str = "api/"):
|
|
9
|
+
"""
|
|
10
|
+
Mounts a Single Page Application (SPA) to the FastAPI app using ASGI StaticFiles.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
app: The FastAPI application instance.
|
|
14
|
+
build_dir: The directory containing the built frontend files (dist).
|
|
15
|
+
api_prefix: The prefix for API routes to exclude from SPA fallback.
|
|
16
|
+
"""
|
|
17
|
+
# Check if build_dir exists to avoid startup errors
|
|
18
|
+
if not os.path.isdir(build_dir):
|
|
19
|
+
print(
|
|
20
|
+
f"Warning: SPA build directory '{build_dir}' does not exist. SPA will not be served."
|
|
21
|
+
)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
app.mount(
|
|
25
|
+
"/", SPAStaticFiles(directory=build_dir, api_prefix=api_prefix), name="spa"
|
|
26
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Compiled Regex Patterns
|
|
4
|
+
ROOT_PATH_REGEX = re.compile(r"^/$")
|
|
5
|
+
# Vite Hashed File: name-hash.ext (8 chars of alphanumeric + '-' + '_')
|
|
6
|
+
HASHED_FILE_REGEX = re.compile(r"-[a-zA-Z0-9_-]{8}\.[a-zA-Z0-9]+$")
|
|
7
|
+
# Route Param Regex: :name(regex)?
|
|
8
|
+
ROUTE_PARAM_REGEX = re.compile(r"(:[a-zA-Z0-9_]+)(\(.*?\))?(\?)?")
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import mimetypes
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from re import Pattern
|
|
6
|
+
|
|
7
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
from starlette.staticfiles import StaticFiles
|
|
10
|
+
from starlette.types import Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
from .constants import HASHED_FILE_REGEX, ROOT_PATH_REGEX, ROUTE_PARAM_REGEX
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SPAStaticFiles(StaticFiles):
|
|
16
|
+
def __init__(self, directory: str, api_prefix: str = "api/"):
|
|
17
|
+
self.api_prefix = api_prefix.strip("/")
|
|
18
|
+
self.build_dir = directory
|
|
19
|
+
|
|
20
|
+
# Split routes into static (O(1) lookup) and dynamic (Regex)
|
|
21
|
+
self.static_routes: set[str] = set()
|
|
22
|
+
self.dynamic_routes: list[Pattern] = []
|
|
23
|
+
|
|
24
|
+
# Preloaded responses
|
|
25
|
+
self.index_response: Response | None = None
|
|
26
|
+
self.not_found_response: Response | None = None
|
|
27
|
+
self.file_cache: dict[str, Response] = {}
|
|
28
|
+
|
|
29
|
+
self.load_manifest()
|
|
30
|
+
self.preload_all_assets()
|
|
31
|
+
super().__init__(directory=directory, html=True)
|
|
32
|
+
|
|
33
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
34
|
+
await super().__call__(scope, receive, send)
|
|
35
|
+
|
|
36
|
+
def load_manifest(self):
|
|
37
|
+
manifest_path = os.path.join(self.build_dir, "spa-manifest.json")
|
|
38
|
+
if os.path.exists(manifest_path):
|
|
39
|
+
try:
|
|
40
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
routes = data.get("routes", [])
|
|
43
|
+
|
|
44
|
+
for route in routes:
|
|
45
|
+
if self._is_static_route(route):
|
|
46
|
+
# Normalize: ensure leading slash, no trailing slash (unless root)
|
|
47
|
+
normalized = "/" + route.strip("/")
|
|
48
|
+
if normalized == "//":
|
|
49
|
+
normalized = "/"
|
|
50
|
+
self.static_routes.add(normalized)
|
|
51
|
+
else:
|
|
52
|
+
self.dynamic_routes.append(self._route_to_regex(route))
|
|
53
|
+
|
|
54
|
+
print(
|
|
55
|
+
f"Loaded {len(self.static_routes)} static routes and {len(self.dynamic_routes)} dynamic routes from manifest."
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"Error loading SPA manifest: {e}")
|
|
59
|
+
else:
|
|
60
|
+
print(
|
|
61
|
+
f"SPA manifest not found at {manifest_path}. SPA fallback validation disabled."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def preload_all_assets(self):
|
|
65
|
+
"""Preload all static files into memory for extreme performance."""
|
|
66
|
+
print(f"Preloading all assets from {self.build_dir}...")
|
|
67
|
+
count = 0
|
|
68
|
+
total_size = 0
|
|
69
|
+
|
|
70
|
+
for root, _, files in os.walk(self.build_dir):
|
|
71
|
+
for filename in files:
|
|
72
|
+
file_path = os.path.join(root, filename)
|
|
73
|
+
rel_path = os.path.relpath(file_path, self.build_dir)
|
|
74
|
+
# Normalize to URL path (forward slashes)
|
|
75
|
+
url_path = rel_path.replace("\\", "/")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with open(file_path, "rb") as f:
|
|
79
|
+
content = f.read()
|
|
80
|
+
media_type, _ = mimetypes.guess_type(file_path)
|
|
81
|
+
response = Response(
|
|
82
|
+
content=content,
|
|
83
|
+
media_type=media_type or "application/octet-stream",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Apply aggressive caching for hashed files (Vite default: name-hash.ext)
|
|
87
|
+
# Hash is typically 8 chars of alphanumeric + '-' + '_'
|
|
88
|
+
if HASHED_FILE_REGEX.search(filename):
|
|
89
|
+
response.headers["Cache-Control"] = (
|
|
90
|
+
"public, max-age=31536000, immutable"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.file_cache[url_path] = response
|
|
94
|
+
|
|
95
|
+
# Set special responses
|
|
96
|
+
if url_path == "index.html":
|
|
97
|
+
self.index_response = Response(
|
|
98
|
+
content=content, media_type="text/html"
|
|
99
|
+
)
|
|
100
|
+
elif url_path == "404.html":
|
|
101
|
+
self.not_found_response = Response(
|
|
102
|
+
content=content, status_code=404, media_type="text/html"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
count += 1
|
|
106
|
+
total_size += len(content)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"Error caching {file_path}: {e}")
|
|
109
|
+
|
|
110
|
+
print(f"Cached {count} files ({total_size / 1024 / 1024:.2f} MB) in memory.")
|
|
111
|
+
|
|
112
|
+
def _is_static_route(self, route: str) -> bool:
|
|
113
|
+
"""Check if a route contains any dynamic parameters."""
|
|
114
|
+
return ":" not in route and "*" not in route and "(" not in route
|
|
115
|
+
|
|
116
|
+
def _route_to_regex(self, route: str) -> Pattern:
|
|
117
|
+
"""
|
|
118
|
+
Convert Vue Router path to Regex Pattern.
|
|
119
|
+
"""
|
|
120
|
+
if route == "/":
|
|
121
|
+
return ROOT_PATH_REGEX
|
|
122
|
+
|
|
123
|
+
parts = route.split("/")
|
|
124
|
+
pattern = "^"
|
|
125
|
+
|
|
126
|
+
for part in parts:
|
|
127
|
+
if not part:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Regex to identify params:
|
|
131
|
+
# Group 1: :name
|
|
132
|
+
# Group 2: (custom_regex) - optional
|
|
133
|
+
# Group 3: ? - optional
|
|
134
|
+
|
|
135
|
+
segment_pattern = ""
|
|
136
|
+
last_idx = 0
|
|
137
|
+
is_optional_segment = False
|
|
138
|
+
|
|
139
|
+
matches = list(ROUTE_PARAM_REGEX.finditer(part))
|
|
140
|
+
|
|
141
|
+
if not matches:
|
|
142
|
+
# Static segment
|
|
143
|
+
segment_pattern = re.escape(part)
|
|
144
|
+
else:
|
|
145
|
+
for match in matches:
|
|
146
|
+
# Append static text before match
|
|
147
|
+
static_text = part[last_idx : match.start()]
|
|
148
|
+
segment_pattern += re.escape(static_text)
|
|
149
|
+
|
|
150
|
+
# Handle Param
|
|
151
|
+
custom_regex = match.group(2)
|
|
152
|
+
is_optional = bool(match.group(3))
|
|
153
|
+
|
|
154
|
+
if custom_regex:
|
|
155
|
+
# Strip parens to get the regex content: :slug(.*) -> (.*) -> .*
|
|
156
|
+
inner_regex = custom_regex[1:-1]
|
|
157
|
+
segment_pattern += inner_regex
|
|
158
|
+
else:
|
|
159
|
+
segment_pattern += r"[^/]+"
|
|
160
|
+
|
|
161
|
+
# Logic for optional segment:
|
|
162
|
+
# If the match covers the ENTIRE part and is optional, the whole segment (including slash) is optional
|
|
163
|
+
if is_optional and len(part) == len(match.group(0)):
|
|
164
|
+
is_optional_segment = True
|
|
165
|
+
|
|
166
|
+
last_idx = match.end()
|
|
167
|
+
|
|
168
|
+
# Append remaining static text
|
|
169
|
+
segment_pattern += re.escape(part[last_idx:])
|
|
170
|
+
|
|
171
|
+
# Append to main pattern
|
|
172
|
+
if is_optional_segment:
|
|
173
|
+
pattern += f"(?:/{segment_pattern})?"
|
|
174
|
+
else:
|
|
175
|
+
pattern += f"/{segment_pattern}"
|
|
176
|
+
|
|
177
|
+
pattern += "$"
|
|
178
|
+
return re.compile(pattern)
|
|
179
|
+
|
|
180
|
+
def is_valid_route(self, path: str) -> bool:
|
|
181
|
+
# If no routes loaded (no manifest), allow all (backward compatibility)
|
|
182
|
+
if not self.static_routes and not self.dynamic_routes:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
# Ensure path starts with /
|
|
186
|
+
check_path = path if path.startswith("/") else "/" + path
|
|
187
|
+
|
|
188
|
+
# 1. Fast O(1) Lookup
|
|
189
|
+
if check_path in self.static_routes:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
# 2. Regex Lookup
|
|
193
|
+
for pattern in self.dynamic_routes:
|
|
194
|
+
if pattern.match(check_path):
|
|
195
|
+
return True
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
async def get_response(self, path: str, scope: Scope):
|
|
199
|
+
# Normalize path separators for Windows
|
|
200
|
+
lookup_path = path.replace("\\", "/")
|
|
201
|
+
|
|
202
|
+
# 0. Check memory cache
|
|
203
|
+
if lookup_path in self.file_cache:
|
|
204
|
+
return self.file_cache[lookup_path]
|
|
205
|
+
|
|
206
|
+
# Check for index.html in directory (simulating StaticFiles behavior)
|
|
207
|
+
potential_index = f"{lookup_path}/index.html".lstrip("/")
|
|
208
|
+
if potential_index in self.file_cache:
|
|
209
|
+
return self.file_cache[potential_index]
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
# 1. Try to serve static file directly
|
|
213
|
+
response = await super().get_response(path, scope)
|
|
214
|
+
if response.status_code == 404:
|
|
215
|
+
raise StarletteHTTPException(status_code=404)
|
|
216
|
+
return response
|
|
217
|
+
except (StarletteHTTPException, OSError) as e:
|
|
218
|
+
# Handle 404s and OSErrors (e.g., invalid filenames on Windows like 'test:case')
|
|
219
|
+
is_404 = isinstance(e, StarletteHTTPException) and e.status_code == 404
|
|
220
|
+
is_os_error = isinstance(e, OSError)
|
|
221
|
+
|
|
222
|
+
if not (is_404 or is_os_error):
|
|
223
|
+
raise e
|
|
224
|
+
|
|
225
|
+
# 2. If not found or invalid file path, check if it is an API route
|
|
226
|
+
# API routes should strictly fail if not handled by FastAPI router
|
|
227
|
+
if path.startswith(self.api_prefix) or path.startswith(
|
|
228
|
+
f"{self.api_prefix}/"
|
|
229
|
+
):
|
|
230
|
+
raise e
|
|
231
|
+
|
|
232
|
+
# 3. Validate against SPA routes (Strict Mode)
|
|
233
|
+
# Normalize path separators for Windows and construct request path
|
|
234
|
+
normalized_path = path.replace("\\", "/")
|
|
235
|
+
request_path = "/" + normalized_path.lstrip("/")
|
|
236
|
+
|
|
237
|
+
if self.is_valid_route(request_path):
|
|
238
|
+
if self.index_response:
|
|
239
|
+
return self.index_response
|
|
240
|
+
|
|
241
|
+
# 4. If not a valid route, return 404.html if exists, else propagate exception
|
|
242
|
+
if self.not_found_response:
|
|
243
|
+
return self.not_found_response
|
|
244
|
+
|
|
245
|
+
raise e
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
monosite/__init__.py,sha256=juCIBS1v_J1sJufJn9nbNRSDpbDIG0bt6PZAhFsLaJw,323
|
|
2
|
+
monosite/_version.py,sha256=qf6R-J7-UyuABBo8c0HgaquJ8bejVbf07HodXgwAwgQ,704
|
|
3
|
+
monosite/cli/main.py,sha256=Nq7DDTeqF2YzmJLqIUalYm1RdjCVK_fgtolw2Lpp57Q,288
|
|
4
|
+
monosite/core/spa/__init__.py,sha256=x-woFOS-SU9upW3B-M6qhzjIz_pBhRbh8ZwjdRHW9Xc,804
|
|
5
|
+
monosite/core/spa/constants.py,sha256=Ld2BVxbAPUFfX0GF-VjnTv8uvvoBP0_BWAfIr70f5jg,316
|
|
6
|
+
monosite/core/spa/static_files.py,sha256=db7bWXMRqkM3HZViK4YVptbH10oaq6dOh3REu8vHjd8,9566
|
|
7
|
+
monosite/template/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
monosite-0.0.1.dist-info/METADATA,sha256=a3N3jNKAvDQPaMP1KsjxJ2rjPG9gJKyo-FU5zBZYToA,140
|
|
9
|
+
monosite-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
monosite-0.0.1.dist-info/entry_points.txt,sha256=Z02DbkMhcqNCMLWl0Vr7m877IH13rq4exu17_8l53Oo,52
|
|
11
|
+
monosite-0.0.1.dist-info/RECORD,,
|