Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 97c0f42

Browse filesBrowse files
authored
allow users to configure html head (#835)
* allow users to configure html head * add tests * fix minor oversights * fix use_debug hook * fix types * test head customization * fix typing/docstring issues * fix docs * fix type anno * remove indent + simplify implementation * add changelog * add test case for data- attributes * use lxml for to html str * fix tsts * add final test * minor improvements * add comment * refine camel to dash conversion * Update test_utils.py
1 parent f7c553e commit 97c0f42
Copy full SHA for 97c0f42

19 files changed

+551
-265
lines changed

‎docs/source/_custom_js/package-lock.json

Copy file name to clipboardExpand all lines: docs/source/_custom_js/package-lock.json
+1-1Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/source/about/changelog.rst

Copy file name to clipboardExpand all lines: docs/source/about/changelog.rst
+6Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ Unreleased
2626
**Removed**
2727

2828
- :pull:`840` - remove ``IDOM_FEATURE_INDEX_AS_DEFAULT_KEY`` option
29+
- :pull:`835` - ``serve_static_files`` option from backend configuration
30+
31+
**Added**
32+
33+
- :pull:`835` - ability to customize the ``<head>`` element of IDOM's built-in client.
34+
- :pull:`835` - ``vdom_to_html`` utility function.
2935

3036

3137
v0.41.0

‎src/client/index.html

Copy file name to clipboardExpand all lines: src/client/index.html
+2-6Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@
22
<html lang="en">
33
<head>
44
<meta charset="utf-8" />
5-
<link
6-
rel="icon"
7-
href="public/idom-logo-square-small.svg"
8-
type="image/svg+xml"
9-
/>
10-
<title>IDOM</title>
5+
<!-- we replace this with user-provided head elements -->
6+
{__head__}
117
</head>
128
<body>
139
<div id="app"></div>

‎src/idom/__init__.py

Copy file name to clipboardExpand all lines: src/idom/__init__.py
+2-1Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from .core.layout import Layout
1919
from .core.serve import Stop
2020
from .core.vdom import vdom
21-
from .utils import Ref, html_to_vdom
21+
from .utils import Ref, html_to_vdom, vdom_to_html
2222
from .widgets import hotswap
2323

2424

@@ -53,6 +53,7 @@
5353
"use_ref",
5454
"use_scope",
5555
"use_state",
56+
"vdom_to_html",
5657
"vdom",
5758
"web",
5859
]

‎src/idom/backend/_asgi.py

Copy file name to clipboardExpand all lines: src/idom/backend/_asgi.py
-42Lines changed: 0 additions & 42 deletions
This file was deleted.

‎src/idom/backend/_common.py

Copy file name to clipboard
+130Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
from dataclasses import dataclass
6+
from pathlib import Path, PurePosixPath
7+
from typing import Any, Awaitable, Sequence, cast
8+
9+
from asgiref.typing import ASGIApplication
10+
from uvicorn.config import Config as UvicornConfig
11+
from uvicorn.server import Server as UvicornServer
12+
13+
from idom import __file__ as _idom_file_path
14+
from idom import html
15+
from idom.config import IDOM_WEB_MODULES_DIR
16+
from idom.core.types import VdomDict
17+
from idom.utils import vdom_to_html
18+
19+
20+
PATH_PREFIX = PurePosixPath("/_idom")
21+
MODULES_PATH = PATH_PREFIX / "modules"
22+
ASSETS_PATH = PATH_PREFIX / "assets"
23+
STREAM_PATH = PATH_PREFIX / "stream"
24+
25+
CLIENT_BUILD_DIR = Path(_idom_file_path).parent / "_client"
26+
27+
28+
async def serve_development_asgi(
29+
app: ASGIApplication | Any,
30+
host: str,
31+
port: int,
32+
started: asyncio.Event | None,
33+
) -> None:
34+
"""Run a development server for starlette"""
35+
server = UvicornServer(
36+
UvicornConfig(
37+
app,
38+
host=host,
39+
port=port,
40+
loop="asyncio",
41+
reload=True,
42+
)
43+
)
44+
45+
coros: list[Awaitable[Any]] = [server.serve()]
46+
47+
if started:
48+
coros.append(_check_if_started(server, started))
49+
50+
try:
51+
await asyncio.gather(*coros)
52+
finally:
53+
await asyncio.wait_for(server.shutdown(), timeout=3)
54+
55+
56+
async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None:
57+
while not server.started:
58+
await asyncio.sleep(0.2)
59+
started.set()
60+
61+
62+
def safe_client_build_dir_path(path: str) -> Path:
63+
"""Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
64+
return traversal_safe_path(
65+
CLIENT_BUILD_DIR,
66+
*("index.html" if path in ("", "/") else path).split("/"),
67+
)
68+
69+
70+
def safe_web_modules_dir_path(path: str) -> Path:
71+
"""Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`"""
72+
return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/"))
73+
74+
75+
def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path:
76+
"""Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir."""
77+
root = os.path.abspath(root)
78+
79+
# Resolve relative paths but not symlinks - symlinks should be ok since their
80+
# presence and where they point is under the control of the developer.
81+
path = os.path.abspath(os.path.join(root, *unsafe))
82+
83+
if os.path.commonprefix([root, path]) != root:
84+
# If the common prefix is not root directory we resolved outside the root dir
85+
raise ValueError("Unsafe path")
86+
87+
return Path(path)
88+
89+
90+
def read_client_index_html(options: CommonOptions) -> str:
91+
return (
92+
(CLIENT_BUILD_DIR / "index.html")
93+
.read_text()
94+
.format(__head__=vdom_head_elements_to_html(options.head))
95+
)
96+
97+
98+
def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str:
99+
if isinstance(head, str):
100+
return head
101+
elif isinstance(head, dict):
102+
if head.get("tagName") == "head":
103+
head = cast(VdomDict, {**head, "tagName": ""})
104+
return vdom_to_html(head)
105+
else:
106+
return vdom_to_html(html._(head))
107+
108+
109+
@dataclass
110+
class CommonOptions:
111+
"""Options for IDOM's built-in backed server implementations"""
112+
113+
head: Sequence[VdomDict] | VdomDict | str = (
114+
html.title("IDOM"),
115+
html.link(
116+
{
117+
"rel": "icon",
118+
"href": "_idom/assets/idom-logo-square-small.svg",
119+
"type": "image/svg+xml",
120+
}
121+
),
122+
)
123+
"""Add elements to the ``<head>`` of the application.
124+
125+
For example, this can be used to customize the title of the page, link extra
126+
scripts, or load stylesheets.
127+
"""
128+
129+
url_prefix: str = ""
130+
"""The URL prefix where IDOM resources will be served from"""

‎src/idom/backend/_urls.py

Copy file name to clipboardExpand all lines: src/idom/backend/_urls.py
-7Lines changed: 0 additions & 7 deletions
This file was deleted.

‎src/idom/backend/flask.py

Copy file name to clipboardExpand all lines: src/idom/backend/flask.py
+25-28Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from queue import Queue as ThreadQueue
1010
from threading import Event as ThreadEvent
1111
from threading import Thread
12-
from typing import Any, Callable, Dict, NamedTuple, NoReturn, Optional, Union, cast
12+
from typing import Any, Callable, NamedTuple, NoReturn, Optional, cast
1313

1414
from flask import (
1515
Blueprint,
@@ -25,6 +25,16 @@
2525
from werkzeug.serving import BaseWSGIServer, make_server
2626

2727
import idom
28+
from idom.backend._common import (
29+
ASSETS_PATH,
30+
MODULES_PATH,
31+
PATH_PREFIX,
32+
STREAM_PATH,
33+
CommonOptions,
34+
read_client_index_html,
35+
safe_client_build_dir_path,
36+
safe_web_modules_dir_path,
37+
)
2838
from idom.backend.hooks import ConnectionContext
2939
from idom.backend.hooks import use_connection as _use_connection
3040
from idom.backend.types import Connection, Location
@@ -33,13 +43,6 @@
3343
from idom.core.types import ComponentType, RootComponentConstructor
3444
from idom.utils import Ref
3545

36-
from ._urls import ASSETS_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH
37-
from .utils import (
38-
CLIENT_BUILD_DIR,
39-
safe_client_build_dir_path,
40-
safe_web_modules_dir_path,
41-
)
42-
4346

4447
logger = logging.getLogger(__name__)
4548

@@ -134,21 +137,15 @@ def use_connection() -> Connection[_FlaskCarrier]:
134137

135138

136139
@dataclass
137-
class Options:
138-
"""Render server config for :class:`FlaskRenderServer`"""
140+
class Options(CommonOptions):
141+
"""Render server config for :func:`idom.backend.flask.configure`"""
139142

140-
cors: Union[bool, Dict[str, Any]] = False
143+
cors: bool | dict[str, Any] = False
141144
"""Enable or configure Cross Origin Resource Sharing (CORS)
142145
143146
For more information see docs for ``flask_cors.CORS``
144147
"""
145148

146-
serve_static_files: bool = True
147-
"""Whether or not to serve static files (i.e. web modules)"""
148-
149-
url_prefix: str = ""
150-
"""The URL prefix where IDOM resources will be served from"""
151-
152149

153150
def _setup_common_routes(
154151
api_blueprint: Blueprint,
@@ -160,20 +157,20 @@ def _setup_common_routes(
160157
cors_params = cors_options if isinstance(cors_options, dict) else {}
161158
CORS(api_blueprint, **cors_params)
162159

163-
if options.serve_static_files:
160+
@api_blueprint.route(f"/{ASSETS_PATH.name}/<path:path>")
161+
def send_assets_dir(path: str = "") -> Any:
162+
return send_file(safe_client_build_dir_path(f"assets/{path}"))
164163

165-
@api_blueprint.route(f"/{ASSETS_PATH.name}/<path:path>")
166-
def send_assets_dir(path: str = "") -> Any:
167-
return send_file(safe_client_build_dir_path(f"assets/{path}"))
164+
@api_blueprint.route(f"/{MODULES_PATH.name}/<path:path>")
165+
def send_modules_dir(path: str = "") -> Any:
166+
return send_file(safe_web_modules_dir_path(path))
168167

169-
@api_blueprint.route(f"/{MODULES_PATH.name}/<path:path>")
170-
def send_modules_dir(path: str = "") -> Any:
171-
return send_file(safe_web_modules_dir_path(path))
168+
index_html = read_client_index_html(options)
172169

173-
@spa_blueprint.route("/")
174-
@spa_blueprint.route("/<path:_>")
175-
def send_client_dir(_: str = "") -> Any:
176-
return send_file(CLIENT_BUILD_DIR / "index.html")
170+
@spa_blueprint.route("/")
171+
@spa_blueprint.route("/<path:_>")
172+
def send_client_dir(_: str = "") -> Any:
173+
return index_html
177174

178175

179176
def _setup_single_view_dispatcher_route(

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.