webdev 2.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.
webdev/run.py ADDED
@@ -0,0 +1,124 @@
1
+ # Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
2
+ # Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing,
11
+ # software distributed under the License is distributed on an
12
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13
+ # KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations
15
+ # under the License.
16
+
17
+ """
18
+ High-level runner: select a port, build the app, print the banner and start
19
+ Uvicorn (optionally over HTTPS).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+
26
+ import uvicorn
27
+
28
+ from .config import Settings
29
+ from .net import NoFreePortError, build_urls, find_free_port, render_qr
30
+ from .server import create_app
31
+ from .tls import ensure_tls_files
32
+
33
+ log = logging.getLogger('webdev')
34
+
35
+
36
+ def _print_banner(settings: Settings, port: int) -> None:
37
+ """
38
+ Print the startup banner with the served path and access URLs.
39
+
40
+ Builds the list of reachable URLs, prints the bound address and each URL,
41
+ optionally renders a QR code for the last URL, and reminds the user how to
42
+ stop the server.
43
+
44
+ :param settings: Normalized runtime settings describing the server.
45
+ :param port: The resolved TCP port the server is bound to.
46
+ """
47
+ urls = build_urls(settings.ip, port, settings.https)
48
+ print('WebDev {} serving {}'.format(_version(), settings.path))
49
+ print(f'Bound to {settings.ip}:{port}')
50
+ for url in urls:
51
+ print(f' -> {url}')
52
+ if settings.qr and urls:
53
+ qr = render_qr(urls[-1])
54
+ if qr:
55
+ print(qr)
56
+ print('Press Ctrl+C to stop.')
57
+
58
+
59
+ def _version() -> str:
60
+ """
61
+ Return the installed WebDev package version string.
62
+
63
+ :return: The value of :data:`webdev.__version__`.
64
+ """
65
+ from . import __version__
66
+
67
+ return __version__
68
+
69
+
70
+ def serve(settings: Settings) -> int:
71
+ """
72
+ Resolve a port, build the app and run the server.
73
+
74
+ Normalizes the given settings, selects a free port, optionally prepares
75
+ TLS certificate files for HTTPS, builds the ASGI application, prints the
76
+ startup banner and runs Uvicorn until interrupted.
77
+
78
+ :param settings: The runtime settings controlling how the server runs.
79
+ :return: A process exit code: ``0`` on success, ``1`` on startup failure.
80
+ """
81
+ settings = settings.normalized()
82
+
83
+ try:
84
+ port = find_free_port(settings.ip, settings.port, settings.range)
85
+ except NoFreePortError as error:
86
+ log.error(str(error))
87
+ return 1
88
+
89
+ cert_file: str | None = None
90
+ key_file: str | None = None
91
+ if settings.https:
92
+ from .tls import TLSUnavailableError
93
+
94
+ try:
95
+ cert_file, key_file = ensure_tls_files(settings.cert, settings.key)
96
+ except TLSUnavailableError as error:
97
+ log.error(str(error))
98
+ return 1
99
+
100
+ app = create_app(settings)
101
+
102
+ _print_banner(settings, port)
103
+
104
+ log_level = (
105
+ 'debug'
106
+ if settings.verbose >= 3
107
+ else ('info' if settings.verbose >= 2 else 'warning')
108
+ )
109
+
110
+ config = uvicorn.Config(
111
+ app,
112
+ host=settings.ip,
113
+ port=port,
114
+ log_level=log_level,
115
+ access_log=settings.verbose >= 2,
116
+ ssl_certfile=cert_file,
117
+ ssl_keyfile=key_file,
118
+ )
119
+ server = uvicorn.Server(config)
120
+ try:
121
+ server.run()
122
+ except KeyboardInterrupt: # pragma: no cover - handled by uvicorn too
123
+ print('Good bye!')
124
+ return 0
webdev/server.py ADDED
@@ -0,0 +1,336 @@
1
+ # Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
2
+ # Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing,
11
+ # software distributed under the License is distributed on an
12
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13
+ # KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations
15
+ # under the License.
16
+
17
+ """
18
+ Starlette application factory and Uvicorn runner.
19
+
20
+ Builds the ASGI application that serves static files and directory listings,
21
+ optionally with live reload, gzip, CORS, SPA fallback, and no-cache headers,
22
+ based on a resolved :class:`~webdev.config.Settings` instance.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import logging
30
+ from pathlib import Path
31
+
32
+ from starlette.applications import Starlette
33
+ from starlette.middleware import Middleware
34
+ from starlette.middleware.cors import CORSMiddleware
35
+ from starlette.middleware.gzip import GZipMiddleware
36
+ from starlette.requests import Request
37
+ from starlette.responses import (
38
+ FileResponse,
39
+ HTMLResponse,
40
+ RedirectResponse,
41
+ Response,
42
+ )
43
+ from starlette.routing import Route, WebSocketRoute
44
+ from starlette.websockets import WebSocket, WebSocketDisconnect
45
+
46
+ from .config import Settings
47
+ from .index import render_index, validate_theme
48
+ from .livereload import (
49
+ CLIENT_SNIPLET,
50
+ LIVERELOAD_PATH,
51
+ ReloadHub,
52
+ watch_tree,
53
+ )
54
+ from .static import (
55
+ NO_CACHE_HEADERS,
56
+ find_index_file,
57
+ guess_media_type,
58
+ resolve_fs_path,
59
+ )
60
+
61
+ log = logging.getLogger('webdev')
62
+
63
+ _HTML_SUFFIXES = ('.html', '.htm')
64
+
65
+
66
+ def _inject_livereload(html: bytes, enabled: bool) -> bytes:
67
+ """
68
+ Inject the reload snippet before ``</body>`` (or append) when enabled.
69
+
70
+ When *enabled* is false the HTML is returned unchanged. Otherwise the
71
+ client snippet is inserted just before the last ``</body>`` tag, or appended
72
+ if no such tag exists.
73
+
74
+ :param html: The HTML document bytes to augment.
75
+ :param enabled: Whether live reload injection is active.
76
+ :return: The HTML bytes, with the snippet injected when *enabled*.
77
+ """
78
+ if not enabled:
79
+ return html
80
+ snippet = CLIENT_SNIPLET.encode('utf-8')
81
+ lowered = html.lower()
82
+ idx = lowered.rfind(b'</body>')
83
+ if idx != -1:
84
+ return html[:idx] + snippet + html[idx:]
85
+ return html + snippet
86
+
87
+
88
+ def _cache_headers(settings: Settings) -> dict[str, str]:
89
+ """
90
+ Return response headers that control client caching.
91
+
92
+ :param settings: The resolved runtime settings.
93
+ :return: No-cache headers when caching is disabled, else an empty mapping.
94
+ """
95
+ return dict(NO_CACHE_HEADERS) if settings.no_cache else {}
96
+
97
+
98
+ def _not_found(settings: Settings) -> Response:
99
+ """
100
+ Build a plain-text ``404 Not Found`` response.
101
+
102
+ :param settings: The resolved runtime settings.
103
+ :return: A :class:`Response` with status code 404.
104
+ """
105
+ return Response('404 Not Found', status_code=404, media_type='text/plain')
106
+
107
+
108
+ def _html_response(
109
+ body: bytes, settings: Settings, status_code: int = 200
110
+ ) -> HTMLResponse:
111
+ """
112
+ Build an HTML response with live-reload injection and cache headers.
113
+
114
+ :param body: The raw HTML document bytes.
115
+ :param settings: The resolved runtime settings.
116
+ :param status_code: HTTP status code for the response.
117
+ :return: An :class:`HTMLResponse` ready to be returned to the client.
118
+ """
119
+ body = _inject_livereload(body, settings.reload)
120
+ return HTMLResponse(body, status_code=status_code, headers=_cache_headers(settings))
121
+
122
+
123
+ async def _serve_file(path: Path, settings: Settings) -> Response:
124
+ """
125
+ Serve a regular file, injecting live-reload JS into HTML documents.
126
+
127
+ HTML files are read into memory so the reload snippet can be injected;
128
+ other files are streamed via :class:`FileResponse`.
129
+
130
+ :param path: Filesystem path of the file to serve.
131
+ :param settings: The resolved runtime settings.
132
+ :return: An HTML, file, or 404 response depending on the file and I/O.
133
+ """
134
+ if path.suffix.lower() in _HTML_SUFFIXES:
135
+ try:
136
+ body = path.read_bytes()
137
+ except OSError:
138
+ return _not_found(settings)
139
+ return _html_response(body, settings)
140
+ return FileResponse(
141
+ path,
142
+ media_type=guess_media_type(path),
143
+ headers=_cache_headers(settings),
144
+ )
145
+
146
+
147
+ async def _serve_directory(
148
+ request: Request, fs_dir: Path, settings: Settings
149
+ ) -> Response:
150
+ """
151
+ Serve a directory as an index page or its index file.
152
+
153
+ Redirects to a trailing-slash URL when needed, serves a directory index
154
+ file if present, and otherwise renders a themed directory listing.
155
+
156
+ :param request: The incoming HTTP request.
157
+ :param fs_dir: Filesystem path of the directory to serve.
158
+ :param settings: The resolved runtime settings.
159
+ :return: A redirect, file, or rendered index HTML response.
160
+ """
161
+ url_path = request.url.path
162
+ # Ensure a trailing slash so relative links resolve correctly.
163
+ if not url_path.endswith('/'):
164
+ return RedirectResponse(url_path + '/', status_code=308)
165
+
166
+ index_file = find_index_file(fs_dir)
167
+ if index_file is not None:
168
+ return await _serve_file(index_file, settings)
169
+
170
+ html = render_index(
171
+ fs_dir=fs_dir,
172
+ url_path=url_path,
173
+ served_root=Path(settings.path),
174
+ theme=settings.theme,
175
+ live_reload=settings.reload,
176
+ )
177
+ return _html_response(html.encode('utf-8'), settings)
178
+
179
+
180
+ async def _handle(request: Request) -> Response:
181
+ """
182
+ Main request handler resolving a URL path to a file or directory.
183
+
184
+ Rejects paths that escape the served root, applies the optional SPA
185
+ fallback for unknown routes, and dispatches to the file or directory
186
+ handler as appropriate.
187
+
188
+ :param request: The incoming HTTP request.
189
+ :return: The response for the resolved resource, or a 404 response.
190
+ """
191
+ settings: Settings = request.app.state.settings
192
+ root = Path(settings.path)
193
+ resolved = resolve_fs_path(root, request.url.path)
194
+
195
+ if resolved.escapes:
196
+ return _not_found(settings)
197
+
198
+ if resolved.fs_path is None:
199
+ # SPA fallback: serve the root index.html for unknown routes.
200
+ if settings.spa:
201
+ index_file = find_index_file(root)
202
+ if index_file is not None:
203
+ return await _serve_file(index_file, settings)
204
+ return _not_found(settings)
205
+
206
+ if resolved.is_dir:
207
+ return await _serve_directory(request, resolved.fs_path, settings)
208
+
209
+ return await _serve_file(resolved.fs_path, settings)
210
+
211
+
212
+ async def _drain(websocket: WebSocket) -> None:
213
+ """
214
+ Consume inbound frames so client disconnects are detected.
215
+
216
+ Runs until the client disconnects, suppressing the resulting
217
+ :class:`WebSocketDisconnect`.
218
+
219
+ :param websocket: The WebSocket connection to drain.
220
+ """
221
+ with contextlib.suppress(WebSocketDisconnect):
222
+ while True:
223
+ await websocket.receive_text()
224
+
225
+
226
+ async def _livereload_endpoint(websocket: WebSocket) -> None:
227
+ """
228
+ WebSocket endpoint that pushes live-reload messages to the browser.
229
+
230
+ Registers with the reload hub, forwards each reload message to the client,
231
+ and stops when the client disconnects, cleaning up its registration.
232
+
233
+ :param websocket: The incoming WebSocket connection.
234
+ """
235
+ hub: ReloadHub = websocket.app.state.hub
236
+ await websocket.accept()
237
+ queue = hub.register()
238
+ receiver = asyncio.create_task(_drain(websocket))
239
+ try:
240
+ while True:
241
+ getter = asyncio.create_task(queue.get())
242
+ done, _pending = await asyncio.wait(
243
+ {getter, receiver}, return_when=asyncio.FIRST_COMPLETED
244
+ )
245
+ if receiver in done:
246
+ getter.cancel()
247
+ break
248
+ message = getter.result()
249
+ await websocket.send_text(message)
250
+ except WebSocketDisconnect:
251
+ pass
252
+ finally:
253
+ receiver.cancel()
254
+ with contextlib.suppress(asyncio.CancelledError):
255
+ await receiver
256
+ hub.unregister(queue)
257
+
258
+
259
+ def _build_lifespan(settings: Settings):
260
+ """
261
+ Create an ASGI lifespan context manager bound to *settings*.
262
+
263
+ The returned lifespan stores settings and a reload hub on the application
264
+ state and, when live reload is enabled, runs a filesystem watcher for the
265
+ lifetime of the application.
266
+
267
+ :param settings: The resolved runtime settings.
268
+ :return: An async lifespan context manager for :class:`Starlette`.
269
+ """
270
+
271
+ @contextlib.asynccontextmanager
272
+ async def lifespan(app: Starlette):
273
+ """
274
+ Manage startup and shutdown of application-wide resources.
275
+
276
+ :param app: The Starlette application being started.
277
+ """
278
+ app.state.settings = settings
279
+ app.state.hub = ReloadHub()
280
+ stop_event = asyncio.Event()
281
+ watcher: asyncio.Task | None = None
282
+ if settings.reload:
283
+ watcher = asyncio.create_task(
284
+ watch_tree(Path(settings.path), app.state.hub, stop_event)
285
+ )
286
+ try:
287
+ yield
288
+ finally:
289
+ stop_event.set()
290
+ if watcher is not None:
291
+ watcher.cancel()
292
+ with contextlib.suppress(asyncio.CancelledError):
293
+ await watcher
294
+
295
+ return lifespan
296
+
297
+
298
+ def create_app(settings: Settings) -> Starlette:
299
+ """
300
+ Build the Starlette application for the given *settings*.
301
+
302
+ Validates the theme, wires up routes (including the live-reload WebSocket
303
+ when enabled) and optional CORS and gzip middleware, and attaches the
304
+ settings to the application state.
305
+
306
+ :param settings: The resolved runtime settings.
307
+ :return: A configured :class:`Starlette` application instance.
308
+ :raises ThemeNotFoundError: If the configured theme does not exist.
309
+ """
310
+ validate_theme(settings.theme)
311
+
312
+ routes: list = []
313
+ if settings.reload:
314
+ routes.append(WebSocketRoute(LIVERELOAD_PATH, _livereload_endpoint))
315
+ routes.append(Route('/{path:path}', _handle, methods=['GET', 'HEAD']))
316
+
317
+ middleware: list[Middleware] = []
318
+ if settings.cors:
319
+ middleware.append(
320
+ Middleware(
321
+ CORSMiddleware,
322
+ allow_origins=['*'],
323
+ allow_methods=['*'],
324
+ allow_headers=['*'],
325
+ )
326
+ )
327
+ if settings.gzip:
328
+ middleware.append(Middleware(GZipMiddleware, minimum_size=500))
329
+
330
+ app = Starlette(
331
+ routes=routes,
332
+ middleware=middleware,
333
+ lifespan=_build_lifespan(settings),
334
+ )
335
+ app.state.settings = settings
336
+ return app
webdev/static.py ADDED
@@ -0,0 +1,195 @@
1
+ # Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
2
+ # Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing,
11
+ # software distributed under the License is distributed on an
12
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13
+ # KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations
15
+ # under the License.
16
+
17
+ """
18
+ Per-request static file resolution.
19
+
20
+ The core fix over the legacy server: paths are resolved against the **live
21
+ filesystem on every request** instead of relying on the process working
22
+ directory. A missing served directory therefore yields a real ``404`` and
23
+ recovers automatically the instant the directory reappears.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import mimetypes
29
+ from pathlib import Path
30
+
31
+ #: Candidate index file names auto-served for a directory.
32
+ INDEX_FILENAMES = ('index.html', 'index.htm')
33
+
34
+ #: Extra extension -> MIME mappings the stdlib ``mimetypes`` module omits.
35
+ #: Registered so common dev/config/source files render inline instead of being
36
+ #: downloaded as ``application/octet-stream``.
37
+ EXTRA_MIME_TYPES = {
38
+ '.toml': 'text/plain',
39
+ '.ini': 'text/plain',
40
+ '.cfg': 'text/plain',
41
+ '.conf': 'text/plain',
42
+ '.lock': 'text/plain',
43
+ '.log': 'text/plain',
44
+ '.env': 'text/plain',
45
+ '.properties': 'text/plain',
46
+ '.rst': 'text/plain',
47
+ '.tsx': 'text/plain',
48
+ '.jsx': 'text/plain',
49
+ '.vue': 'text/plain',
50
+ '.svelte': 'text/plain',
51
+ '.yml': 'text/plain',
52
+ '.yaml': 'text/plain',
53
+ }
54
+
55
+ #: Number of bytes inspected when sniffing whether an unknown file is text.
56
+ _SNIFF_BYTES = 8192
57
+
58
+
59
+ def register_mime_types() -> None:
60
+ """
61
+ Register :data:`EXTRA_MIME_TYPES` with the stdlib ``mimetypes`` module.
62
+
63
+ Adds each extension-to-media-type mapping so later lookups via
64
+ ``mimetypes.guess_type`` recognize these developer-oriented file types.
65
+ """
66
+ for ext, media_type in EXTRA_MIME_TYPES.items():
67
+ mimetypes.add_type(media_type, ext)
68
+
69
+
70
+ register_mime_types()
71
+
72
+
73
+ def _looks_like_text(path: Path) -> bool:
74
+ """
75
+ Heuristically decide whether *path* holds UTF-8 text (no NUL bytes).
76
+
77
+ Reads a leading chunk of the file and treats it as text when it contains no
78
+ NUL byte and decodes as UTF-8, tolerating a multi-byte sequence truncated at
79
+ the chunk boundary. Unreadable files are reported as non-text.
80
+
81
+ :param path: The file to inspect.
82
+ :return: ``True`` if the file appears to be UTF-8 text, else ``False``.
83
+ """
84
+ try:
85
+ with path.open('rb') as fd:
86
+ chunk = fd.read(_SNIFF_BYTES)
87
+ except OSError:
88
+ return False
89
+ if b'\x00' in chunk:
90
+ return False
91
+ try:
92
+ chunk.decode('utf-8')
93
+ except UnicodeDecodeError:
94
+ # A truncated multi-byte sequence at the boundary is still fine.
95
+ try:
96
+ chunk[: chunk.rfind(b' ') + 1 or len(chunk)].decode('utf-8')
97
+ except UnicodeDecodeError:
98
+ return False
99
+ return True
100
+
101
+
102
+ def guess_media_type(path: Path) -> str:
103
+ """
104
+ Resolve a response ``Content-Type`` for *path*.
105
+
106
+ Uses the stdlib ``mimetypes`` registry (augmented by
107
+ :func:`register_mime_types`). For still-unknown extensions, sniffs the file
108
+ and falls back to ``text/plain`` when it looks like text, otherwise
109
+ ``application/octet-stream`` — so arbitrary text files preview inline while
110
+ binaries still download.
111
+
112
+ :param path: The file whose media type should be determined.
113
+ :return: The resolved MIME type string.
114
+ """
115
+ media_type, _encoding = mimetypes.guess_type(path.name)
116
+ if media_type:
117
+ return media_type
118
+ if _looks_like_text(path):
119
+ return 'text/plain'
120
+ return 'application/octet-stream'
121
+
122
+
123
+ #: Headers that disable caching for fresh dev content.
124
+ NO_CACHE_HEADERS = {
125
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
126
+ 'Pragma': 'no-cache',
127
+ 'Expires': '0',
128
+ }
129
+
130
+
131
+ class ResolvedPath:
132
+ """
133
+ Result of resolving a URL path against the served root.
134
+ """
135
+
136
+ __slots__ = ('fs_path', 'is_dir', 'escapes')
137
+
138
+ def __init__(self, fs_path: Path | None, is_dir: bool, escapes: bool) -> None:
139
+ """
140
+ Initialize the resolution result.
141
+
142
+ :param fs_path: The resolved filesystem path, or ``None`` if it does
143
+ not exist.
144
+ :param is_dir: Whether the resolved path is a directory.
145
+ :param escapes: Whether the request attempted to traverse outside the
146
+ served root.
147
+ """
148
+ self.fs_path = fs_path
149
+ self.is_dir = is_dir
150
+ self.escapes = escapes
151
+
152
+
153
+ def resolve_fs_path(root: Path, url_path: str) -> ResolvedPath:
154
+ """
155
+ Resolve *url_path* (the request path) to a filesystem path under *root*.
156
+
157
+ Returns a :class:`ResolvedPath`. ``escapes`` is ``True`` when the request
158
+ attempted to traverse outside *root* (the caller should reject with 404).
159
+ ``fs_path`` is ``None`` when the target does not exist.
160
+
161
+ :param root: The served root directory.
162
+ :param url_path: The decoded request URL path to resolve.
163
+ :return: The resolution result describing the target path.
164
+ """
165
+ # Normalise: strip leading slash, decode nothing here (Starlette already
166
+ # provides a decoded path), collapse via Path semantics.
167
+ relative = url_path.lstrip('/')
168
+ root_resolved = root.resolve(strict=False)
169
+ candidate = (root_resolved / relative).resolve(strict=False)
170
+
171
+ # Traversal safety: the resolved candidate must live under the root.
172
+ if candidate != root_resolved and root_resolved not in candidate.parents:
173
+ return ResolvedPath(None, False, escapes=True)
174
+
175
+ if not candidate.exists():
176
+ return ResolvedPath(None, False, escapes=False)
177
+
178
+ return ResolvedPath(candidate, candidate.is_dir(), escapes=False)
179
+
180
+
181
+ def find_index_file(directory: Path) -> Path | None:
182
+ """
183
+ Return the first existing index file inside *directory*, if any.
184
+
185
+ Checks the candidate names in :data:`INDEX_FILENAMES` in order and returns
186
+ the first one that exists as a regular file.
187
+
188
+ :param directory: The directory to search for an index file.
189
+ :return: The path to the first index file found, or ``None`` if none exist.
190
+ """
191
+ for name in INDEX_FILENAMES:
192
+ candidate = directory / name
193
+ if candidate.is_file():
194
+ return candidate
195
+ return None
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="{{ theme }}">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}Index of {{ url_path }}{% endblock %}</title>
7
+ <style>
8
+ {% include theme ~ "/partials/styles.css.jinja2" %}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="wrap">
13
+ {% block content %}{% endblock %}
14
+ </div>
15
+ {% block scripts %}{% endblock %}
16
+ </body>
17
+ </html>