Skip to content

Commit 97c0f42

Browse 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

19 files changed

+551
-265
lines changed

docs/source/_custom_js/package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/source/about/changelog.rst

+6
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

+2-6
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

+2-1
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

-42
This file was deleted.

src/idom/backend/_common.py

+130
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

-7
This file was deleted.

src/idom/backend/flask.py

+25-28
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)