diff --git a/docs/source/_custom_js/package-lock.json b/docs/source/_custom_js/package-lock.json
index b648d4992..8632d1665 100644
--- a/docs/source/_custom_js/package-lock.json
+++ b/docs/source/_custom_js/package-lock.json
@@ -19,7 +19,7 @@
}
},
"../../../src/client/packages/idom-client-react": {
- "version": "0.40.2",
+ "version": "0.41.0",
"integrity": "sha512-pIK5eNwFSHKXg7ClpASWFVKyZDYxz59MSFpVaX/OqJFkrJaAxBuhKGXNTMXmuyWOL5Iyvb/ErwwDRxQRzMNkfQ==",
"license": "MIT",
"dependencies": {
diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst
index 9af5293b2..4b4cf55be 100644
--- a/docs/source/about/changelog.rst
+++ b/docs/source/about/changelog.rst
@@ -26,6 +26,12 @@ Unreleased
**Removed**
- :pull:`840` - remove ``IDOM_FEATURE_INDEX_AS_DEFAULT_KEY`` option
+- :pull:`835` - ``serve_static_files`` option from backend configuration
+
+**Added**
+
+- :pull:`835` - ability to customize the ``
`` element of IDOM's built-in client.
+- :pull:`835` - ``vdom_to_html`` utility function.
v0.41.0
diff --git a/src/client/index.html b/src/client/index.html
index 622ec0a51..87f0244f2 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -2,12 +2,8 @@
-
- IDOM
+
+ {__head__}
diff --git a/src/idom/__init__.py b/src/idom/__init__.py
index 22b95446f..ad6a34db5 100644
--- a/src/idom/__init__.py
+++ b/src/idom/__init__.py
@@ -18,7 +18,7 @@
from .core.layout import Layout
from .core.serve import Stop
from .core.vdom import vdom
-from .utils import Ref, html_to_vdom
+from .utils import Ref, html_to_vdom, vdom_to_html
from .widgets import hotswap
@@ -53,6 +53,7 @@
"use_ref",
"use_scope",
"use_state",
+ "vdom_to_html",
"vdom",
"web",
]
diff --git a/src/idom/backend/_asgi.py b/src/idom/backend/_asgi.py
deleted file mode 100644
index 94eaa2b88..000000000
--- a/src/idom/backend/_asgi.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from typing import Any, Awaitable
-
-from asgiref.typing import ASGIApplication
-from uvicorn.config import Config as UvicornConfig
-from uvicorn.server import Server as UvicornServer
-
-
-async def serve_development_asgi(
- app: ASGIApplication | Any,
- host: str,
- port: int,
- started: asyncio.Event | None,
-) -> None:
- """Run a development server for starlette"""
- server = UvicornServer(
- UvicornConfig(
- app,
- host=host,
- port=port,
- loop="asyncio",
- reload=True,
- )
- )
-
- coros: list[Awaitable[Any]] = [server.serve()]
-
- if started:
- coros.append(_check_if_started(server, started))
-
- try:
- await asyncio.gather(*coros)
- finally:
- await asyncio.wait_for(server.shutdown(), timeout=3)
-
-
-async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None:
- while not server.started:
- await asyncio.sleep(0.2)
- started.set()
diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py
new file mode 100644
index 000000000..90e2dea5b
--- /dev/null
+++ b/src/idom/backend/_common.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import asyncio
+import os
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import Any, Awaitable, Sequence, cast
+
+from asgiref.typing import ASGIApplication
+from uvicorn.config import Config as UvicornConfig
+from uvicorn.server import Server as UvicornServer
+
+from idom import __file__ as _idom_file_path
+from idom import html
+from idom.config import IDOM_WEB_MODULES_DIR
+from idom.core.types import VdomDict
+from idom.utils import vdom_to_html
+
+
+PATH_PREFIX = PurePosixPath("/_idom")
+MODULES_PATH = PATH_PREFIX / "modules"
+ASSETS_PATH = PATH_PREFIX / "assets"
+STREAM_PATH = PATH_PREFIX / "stream"
+
+CLIENT_BUILD_DIR = Path(_idom_file_path).parent / "_client"
+
+
+async def serve_development_asgi(
+ app: ASGIApplication | Any,
+ host: str,
+ port: int,
+ started: asyncio.Event | None,
+) -> None:
+ """Run a development server for starlette"""
+ server = UvicornServer(
+ UvicornConfig(
+ app,
+ host=host,
+ port=port,
+ loop="asyncio",
+ reload=True,
+ )
+ )
+
+ coros: list[Awaitable[Any]] = [server.serve()]
+
+ if started:
+ coros.append(_check_if_started(server, started))
+
+ try:
+ await asyncio.gather(*coros)
+ finally:
+ await asyncio.wait_for(server.shutdown(), timeout=3)
+
+
+async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None:
+ while not server.started:
+ await asyncio.sleep(0.2)
+ started.set()
+
+
+def safe_client_build_dir_path(path: str) -> Path:
+ """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
+ return traversal_safe_path(
+ CLIENT_BUILD_DIR,
+ *("index.html" if path in ("", "/") else path).split("/"),
+ )
+
+
+def safe_web_modules_dir_path(path: str) -> Path:
+ """Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`"""
+ return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/"))
+
+
+def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path:
+ """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir."""
+ root = os.path.abspath(root)
+
+ # Resolve relative paths but not symlinks - symlinks should be ok since their
+ # presence and where they point is under the control of the developer.
+ path = os.path.abspath(os.path.join(root, *unsafe))
+
+ if os.path.commonprefix([root, path]) != root:
+ # If the common prefix is not root directory we resolved outside the root dir
+ raise ValueError("Unsafe path")
+
+ return Path(path)
+
+
+def read_client_index_html(options: CommonOptions) -> str:
+ return (
+ (CLIENT_BUILD_DIR / "index.html")
+ .read_text()
+ .format(__head__=vdom_head_elements_to_html(options.head))
+ )
+
+
+def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str:
+ if isinstance(head, str):
+ return head
+ elif isinstance(head, dict):
+ if head.get("tagName") == "head":
+ head = cast(VdomDict, {**head, "tagName": ""})
+ return vdom_to_html(head)
+ else:
+ return vdom_to_html(html._(head))
+
+
+@dataclass
+class CommonOptions:
+ """Options for IDOM's built-in backed server implementations"""
+
+ head: Sequence[VdomDict] | VdomDict | str = (
+ html.title("IDOM"),
+ html.link(
+ {
+ "rel": "icon",
+ "href": "_idom/assets/idom-logo-square-small.svg",
+ "type": "image/svg+xml",
+ }
+ ),
+ )
+ """Add elements to the ```` of the application.
+
+ For example, this can be used to customize the title of the page, link extra
+ scripts, or load stylesheets.
+ """
+
+ url_prefix: str = ""
+ """The URL prefix where IDOM resources will be served from"""
diff --git a/src/idom/backend/_urls.py b/src/idom/backend/_urls.py
deleted file mode 100644
index c2523f3d7..000000000
--- a/src/idom/backend/_urls.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from pathlib import PurePosixPath
-
-
-PATH_PREFIX = PurePosixPath("/_idom")
-MODULES_PATH = PATH_PREFIX / "modules"
-ASSETS_PATH = PATH_PREFIX / "assets"
-STREAM_PATH = PATH_PREFIX / "stream"
diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py
index 7a7e18c8d..95c054b83 100644
--- a/src/idom/backend/flask.py
+++ b/src/idom/backend/flask.py
@@ -9,7 +9,7 @@
from queue import Queue as ThreadQueue
from threading import Event as ThreadEvent
from threading import Thread
-from typing import Any, Callable, Dict, NamedTuple, NoReturn, Optional, Union, cast
+from typing import Any, Callable, NamedTuple, NoReturn, Optional, cast
from flask import (
Blueprint,
@@ -25,6 +25,16 @@
from werkzeug.serving import BaseWSGIServer, make_server
import idom
+from idom.backend._common import (
+ ASSETS_PATH,
+ MODULES_PATH,
+ PATH_PREFIX,
+ STREAM_PATH,
+ CommonOptions,
+ read_client_index_html,
+ safe_client_build_dir_path,
+ safe_web_modules_dir_path,
+)
from idom.backend.hooks import ConnectionContext
from idom.backend.hooks import use_connection as _use_connection
from idom.backend.types import Connection, Location
@@ -33,13 +43,6 @@
from idom.core.types import ComponentType, RootComponentConstructor
from idom.utils import Ref
-from ._urls import ASSETS_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH
-from .utils import (
- CLIENT_BUILD_DIR,
- safe_client_build_dir_path,
- safe_web_modules_dir_path,
-)
-
logger = logging.getLogger(__name__)
@@ -134,21 +137,15 @@ def use_connection() -> Connection[_FlaskCarrier]:
@dataclass
-class Options:
- """Render server config for :class:`FlaskRenderServer`"""
+class Options(CommonOptions):
+ """Render server config for :func:`idom.backend.flask.configure`"""
- cors: Union[bool, Dict[str, Any]] = False
+ cors: bool | dict[str, Any] = False
"""Enable or configure Cross Origin Resource Sharing (CORS)
For more information see docs for ``flask_cors.CORS``
"""
- serve_static_files: bool = True
- """Whether or not to serve static files (i.e. web modules)"""
-
- url_prefix: str = ""
- """The URL prefix where IDOM resources will be served from"""
-
def _setup_common_routes(
api_blueprint: Blueprint,
@@ -160,20 +157,20 @@ def _setup_common_routes(
cors_params = cors_options if isinstance(cors_options, dict) else {}
CORS(api_blueprint, **cors_params)
- if options.serve_static_files:
+ @api_blueprint.route(f"/{ASSETS_PATH.name}/")
+ def send_assets_dir(path: str = "") -> Any:
+ return send_file(safe_client_build_dir_path(f"assets/{path}"))
- @api_blueprint.route(f"/{ASSETS_PATH.name}/")
- def send_assets_dir(path: str = "") -> Any:
- return send_file(safe_client_build_dir_path(f"assets/{path}"))
+ @api_blueprint.route(f"/{MODULES_PATH.name}/")
+ def send_modules_dir(path: str = "") -> Any:
+ return send_file(safe_web_modules_dir_path(path))
- @api_blueprint.route(f"/{MODULES_PATH.name}/")
- def send_modules_dir(path: str = "") -> Any:
- return send_file(safe_web_modules_dir_path(path))
+ index_html = read_client_index_html(options)
- @spa_blueprint.route("/")
- @spa_blueprint.route("/")
- def send_client_dir(_: str = "") -> Any:
- return send_file(CLIENT_BUILD_DIR / "index.html")
+ @spa_blueprint.route("/")
+ @spa_blueprint.route("/")
+ def send_client_dir(_: str = "") -> Any:
+ return index_html
def _setup_single_view_dispatcher_route(
diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py
index a18b2cc66..fda9d214f 100644
--- a/src/idom/backend/sanic.py
+++ b/src/idom/backend/sanic.py
@@ -4,7 +4,7 @@
import json
import logging
from dataclasses import dataclass
-from typing import Any, Dict, MutableMapping, Tuple, Union
+from typing import Any, MutableMapping, Tuple
from urllib import parse as urllib_parse
from uuid import uuid4
@@ -24,15 +24,19 @@
)
from idom.core.types import RootComponentConstructor
-from ._asgi import serve_development_asgi
-from ._urls import ASSETS_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH
-from .hooks import ConnectionContext
-from .hooks import use_connection as _use_connection
-from .utils import (
- CLIENT_BUILD_DIR,
+from ._common import (
+ ASSETS_PATH,
+ MODULES_PATH,
+ PATH_PREFIX,
+ STREAM_PATH,
+ CommonOptions,
+ read_client_index_html,
safe_client_build_dir_path,
safe_web_modules_dir_path,
+ serve_development_asgi,
)
+from .hooks import ConnectionContext
+from .hooks import use_connection as _use_connection
logger = logging.getLogger(__name__)
@@ -90,21 +94,15 @@ def use_connection() -> Connection[_SanicCarrier]:
@dataclass
-class Options:
- """Options for :class:`SanicRenderServer`"""
+class Options(CommonOptions):
+ """Render server config for :func:`idom.backend.sanic.configure`"""
- cors: Union[bool, Dict[str, Any]] = False
+ cors: bool | dict[str, Any] = False
"""Enable or configure Cross Origin Resource Sharing (CORS)
For more information see docs for ``sanic_cors.CORS``
"""
- serve_static_files: bool = True
- """Whether or not to serve static files (i.e. web modules)"""
-
- url_prefix: str = ""
- """The URL prefix where IDOM resources will be served from"""
-
def _setup_common_routes(
api_blueprint: Blueprint,
@@ -116,38 +114,38 @@ def _setup_common_routes(
cors_params = cors_options if isinstance(cors_options, dict) else {}
CORS(api_blueprint, **cors_params)
- if options.serve_static_files:
-
- async def single_page_app_files(
- request: request.Request,
- _: str = "",
- ) -> response.HTTPResponse:
- return await response.file(CLIENT_BUILD_DIR / "index.html")
-
- spa_blueprint.add_route(single_page_app_files, "/")
- spa_blueprint.add_route(single_page_app_files, "/<_:path>")
-
- async def asset_files(
- request: request.Request,
- path: str = "",
- ) -> response.HTTPResponse:
- path = urllib_parse.unquote(path)
- return await response.file(safe_client_build_dir_path(f"assets/{path}"))
-
- api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/")
-
- async def web_module_files(
- request: request.Request,
- path: str,
- _: str = "", # this is not used
- ) -> response.HTTPResponse:
- path = urllib_parse.unquote(path)
- return await response.file(
- safe_web_modules_dir_path(path),
- mime_type="text/javascript",
- )
-
- api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/")
+ index_html = read_client_index_html(options)
+
+ async def single_page_app_files(
+ request: request.Request,
+ _: str = "",
+ ) -> response.HTTPResponse:
+ return response.html(index_html)
+
+ spa_blueprint.add_route(single_page_app_files, "/")
+ spa_blueprint.add_route(single_page_app_files, "/<_:path>")
+
+ async def asset_files(
+ request: request.Request,
+ path: str = "",
+ ) -> response.HTTPResponse:
+ path = urllib_parse.unquote(path)
+ return await response.file(safe_client_build_dir_path(f"assets/{path}"))
+
+ api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/")
+
+ async def web_module_files(
+ request: request.Request,
+ path: str,
+ _: str = "", # this is not used
+ ) -> response.HTTPResponse:
+ path = urllib_parse.unquote(path)
+ return await response.file(
+ safe_web_modules_dir_path(path),
+ mime_type="text/javascript",
+ )
+
+ api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/")
def _setup_single_view_dispatcher_route(
diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py
index ed1bc10da..21d5200af 100644
--- a/src/idom/backend/starlette.py
+++ b/src/idom/backend/starlette.py
@@ -4,12 +4,12 @@
import json
import logging
from dataclasses import dataclass
-from typing import Any, Dict, Tuple, Union
+from typing import Any, Awaitable, Callable, Tuple
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
-from starlette.responses import FileResponse
+from starlette.responses import HTMLResponse
from starlette.staticfiles import StaticFiles
from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -25,11 +25,17 @@
)
from idom.core.types import RootComponentConstructor
-from ._asgi import serve_development_asgi
-from ._urls import ASSETS_PATH, MODULES_PATH, STREAM_PATH
+from ._common import (
+ ASSETS_PATH,
+ CLIENT_BUILD_DIR,
+ MODULES_PATH,
+ STREAM_PATH,
+ CommonOptions,
+ read_client_index_html,
+ serve_development_asgi,
+)
from .hooks import ConnectionContext
from .hooks import use_connection as _use_connection
-from .utils import CLIENT_BUILD_DIR
logger = logging.getLogger(__name__)
@@ -86,21 +92,15 @@ def use_connection() -> Connection[WebSocket]:
@dataclass
-class Options:
- """Optionsuration options for :class:`StarletteRenderServer`"""
+class Options(CommonOptions):
+ """Render server config for :func:`idom.backend.starlette.configure`"""
- cors: Union[bool, Dict[str, Any]] = False
+ cors: bool | dict[str, Any] = False
"""Enable or configure Cross Origin Resource Sharing (CORS)
For more information see docs for ``starlette.middleware.cors.CORSMiddleware``
"""
- serve_static_files: bool = True
- """Whether or not to serve static files (i.e. web modules)"""
-
- url_prefix: str = ""
- """The URL prefix where IDOM resources will be served from"""
-
def _setup_common_routes(options: Options, app: Starlette) -> None:
cors_options = options.cors
@@ -114,22 +114,27 @@ def _setup_common_routes(options: Options, app: Starlette) -> None:
# BUG: https://github.com/tiangolo/fastapi/issues/1469
url_prefix = options.url_prefix
- if options.serve_static_files:
- app.mount(
- str(MODULES_PATH),
- StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False),
- )
- app.mount(
- str(ASSETS_PATH),
- StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False),
- )
- # register this last so it takes least priority
- app.add_route(url_prefix + "/", serve_index)
- app.add_route(url_prefix + "/{path:path}", serve_index)
+ app.mount(
+ str(MODULES_PATH),
+ StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False),
+ )
+ app.mount(
+ str(ASSETS_PATH),
+ StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False),
+ )
+ # register this last so it takes least priority
+ index_route = _make_index_route(options)
+ app.add_route(url_prefix + "/", index_route)
+ app.add_route(url_prefix + "/{path:path}", index_route)
+
+
+def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]:
+ index_html = read_client_index_html(options)
+ async def serve_index(request: Request) -> HTMLResponse:
+ return HTMLResponse(index_html)
-async def serve_index(request: Request) -> FileResponse:
- return FileResponse(CLIENT_BUILD_DIR / "index.html")
+ return serve_index
def _setup_single_view_dispatcher_route(
diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py
index 0af4bfb0d..a9a112ffc 100644
--- a/src/idom/backend/tornado.py
+++ b/src/idom/backend/tornado.py
@@ -4,7 +4,6 @@
import json
from asyncio import Queue as AsyncQueue
from asyncio.futures import Future
-from dataclasses import dataclass
from typing import Any, List, Tuple, Type, Union
from urllib.parse import urljoin
@@ -22,16 +21,26 @@
from idom.core.serve import VdomJsonPatch, serve_json_patch
from idom.core.types import ComponentConstructor
-from ._urls import ASSETS_PATH, MODULES_PATH, STREAM_PATH
+from ._common import (
+ ASSETS_PATH,
+ CLIENT_BUILD_DIR,
+ MODULES_PATH,
+ STREAM_PATH,
+ CommonOptions,
+ read_client_index_html,
+)
from .hooks import ConnectionContext
from .hooks import use_connection as _use_connection
-from .utils import CLIENT_BUILD_DIR
+
+
+Options = CommonOptions
+"""Render server config for :func:`idom.backend.tornado.configure`"""
def configure(
app: Application,
component: ComponentConstructor,
- options: Options | None = None,
+ options: CommonOptions | None = None,
) -> None:
"""Configure the necessary IDOM routes on the given app.
@@ -98,50 +107,27 @@ def use_connection() -> Connection[HTTPServerRequest]:
return conn
-@dataclass
-class Options:
- """Render server options for :class:`TornadoRenderServer` subclasses"""
-
- serve_static_files: bool = True
- """Whether or not to serve static files (i.e. web modules)"""
-
- url_prefix: str = ""
- """The URL prefix where IDOM resources will be served from"""
-
-
_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]]
def _setup_common_routes(options: Options) -> _RouteHandlerSpecs:
- handlers: _RouteHandlerSpecs = []
-
- if options.serve_static_files:
- handlers.append(
- (
- rf"{MODULES_PATH}/(.*)",
- StaticFileHandler,
- {"path": str(IDOM_WEB_MODULES_DIR.current)},
- )
- )
-
- handlers.append(
- (
- rf"{ASSETS_PATH}/(.*)",
- StaticFileHandler,
- {"path": str(CLIENT_BUILD_DIR / "assets")},
- )
- )
-
- # register last to give lowest priority
- handlers.append(
- (
- r"/(.*)",
- SpaStaticFileHandler,
- {"path": str(CLIENT_BUILD_DIR)},
- )
- )
-
- return handlers
+ return [
+ (
+ rf"{MODULES_PATH}/(.*)",
+ StaticFileHandler,
+ {"path": str(IDOM_WEB_MODULES_DIR.current)},
+ ),
+ (
+ rf"{ASSETS_PATH}/(.*)",
+ StaticFileHandler,
+ {"path": str(CLIENT_BUILD_DIR / "assets")},
+ ),
+ (
+ r"/(.*)",
+ IndexHandler,
+ {"index_html": read_client_index_html(options)},
+ ),
+ ]
def _add_handler(
@@ -171,9 +157,15 @@ def _setup_single_view_dispatcher_route(
]
-class SpaStaticFileHandler(StaticFileHandler):
- async def get(self, _: str, include_body: bool = True) -> None:
- return await super().get(str(CLIENT_BUILD_DIR / "index.html"), include_body)
+class IndexHandler(RequestHandler):
+
+ _index_html: str
+
+ def initialize(self, index_html: str) -> None:
+ self._index_html = index_html
+
+ async def get(self, _: str) -> None:
+ self.finish(self._index_html)
class ModelStreamHandler(WebSocketHandler):
diff --git a/src/idom/backend/utils.py b/src/idom/backend/utils.py
index a3c5ee51a..bca5d8903 100644
--- a/src/idom/backend/utils.py
+++ b/src/idom/backend/utils.py
@@ -2,22 +2,17 @@
import asyncio
import logging
-import os
import socket
from contextlib import closing
from importlib import import_module
-from pathlib import Path
from typing import Any, Iterator
-import idom
-from idom.config import IDOM_WEB_MODULES_DIR
from idom.types import RootComponentConstructor
from .types import BackendImplementation
logger = logging.getLogger(__name__)
-CLIENT_BUILD_DIR = Path(idom.__file__).parent / "_client"
SUPPORTED_PACKAGES = (
"starlette",
@@ -56,34 +51,6 @@ def run(
asyncio.run(implementation.serve_development_app(app, host, port))
-def safe_client_build_dir_path(path: str) -> Path:
- """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
- return traversal_safe_path(
- CLIENT_BUILD_DIR,
- *("index.html" if path in ("", "/") else path).split("/"),
- )
-
-
-def safe_web_modules_dir_path(path: str) -> Path:
- """Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`"""
- return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/"))
-
-
-def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path:
- """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir."""
- root = os.path.abspath(root)
-
- # Resolve relative paths but not symlinks - symlinks should be ok since their
- # presence and where they point is under the control of the developer.
- path = os.path.abspath(os.path.join(root, *unsafe))
-
- if os.path.commonprefix([root, path]) != root:
- # If the common prefix is not root directory we resolved outside the root dir
- raise ValueError("Unsafe path")
-
- return Path(path)
-
-
def find_available_port(
host: str,
port_min: int = 8000,
diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py
index a1353f090..dd7d0fc4b 100644
--- a/src/idom/core/hooks.py
+++ b/src/idom/core/hooks.py
@@ -23,6 +23,7 @@
from typing_extensions import Protocol
+from idom.config import IDOM_DEBUG_MODE
from idom.utils import Ref
from ._thread_local import ThreadLocal
@@ -212,7 +213,7 @@ def use_debug_value(
memo_func = message if callable(message) else lambda: message
new = use_memo(memo_func, dependencies)
- if old.current != new:
+ if IDOM_DEBUG_MODE.current and old.current != new:
old.current = new
logger.debug(f"{current_hook().component} {new}")
diff --git a/src/idom/utils.py b/src/idom/utils.py
index e176da660..fde800abb 100644
--- a/src/idom/utils.py
+++ b/src/idom/utils.py
@@ -1,8 +1,11 @@
+from __future__ import annotations
+
+import re
from itertools import chain
-from typing import Any, Callable, Generic, Iterable, List, TypeVar, Union
+from typing import Any, Callable, Generic, Iterable, TypeVar, cast
from lxml import etree
-from lxml.html import fragments_fromstring
+from lxml.html import fragments_fromstring, tostring
import idom
from idom.core.types import VdomDict
@@ -56,6 +59,25 @@ def __repr__(self) -> str:
return f"{type(self).__name__}({current})"
+def vdom_to_html(vdom: VdomDict) -> str:
+ """Convert a VDOM dictionary into an HTML string
+
+ Only the following keys are translated to HTML:
+
+ - ``tagName``
+ - ``attributes``
+ - ``children`` (must be strings or more VDOM dicts)
+
+ Parameters:
+ vdom: The VdomDict element to convert to HTML
+ """
+ temp_root = etree.Element("__temp__")
+ _add_vdom_to_etree(temp_root, vdom)
+ html = cast(bytes, tostring(temp_root)).decode()
+ # strip out temp root <__temp__> element
+ return html[10:-11]
+
+
def html_to_vdom(
html: str, *transforms: _ModelTransform, strict: bool = True
) -> VdomDict:
@@ -118,6 +140,10 @@ def html_to_vdom(
return vdom
+class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc]
+ """Raised when an HTML document cannot be parsed using strict parsing."""
+
+
def _etree_to_vdom(
node: etree._Element, transforms: Iterable[_ModelTransform]
) -> VdomDict:
@@ -165,6 +191,48 @@ def _etree_to_vdom(
return vdom
+def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any]) -> None:
+ try:
+ tag = vdom["tagName"]
+ except KeyError as e:
+ raise TypeError(f"Expected a VDOM dict, not {vdom}") from e
+ else:
+ vdom = cast(VdomDict, vdom)
+
+ if tag:
+ element = etree.SubElement(parent, tag)
+ element.attrib.update(
+ _vdom_attr_to_html_str(k, v) for k, v in vdom.get("attributes", {}).items()
+ )
+ else:
+ element = parent
+
+ for c in vdom.get("children", []):
+ if isinstance(c, dict):
+ _add_vdom_to_etree(element, c)
+ else:
+ """
+ LXML handles string children by storing them under `text` and `tail`
+ attributes of Element objects. The `text` attribute, if present, effectively
+ becomes that element's first child. Then the `tail` attribute, if present,
+ becomes a sibling that follows that element. For example, consider the
+ following HTML:
+
+ helloworld
+
+ In this code sample, "hello" is the `text` attribute of the `` element
+ and "world" is the `tail` attribute of that same `` element. It's for
+ this reason that, depending on whether the element being constructed has
+ non-string a child element, we need to assign a `text` vs `tail` attribute
+ to that element or the last non-string child respectively.
+ """
+ if len(element):
+ last_child = element[-1]
+ last_child.tail = f"{last_child.tail or ''}{c}"
+ else:
+ element.text = f"{element.text or ''}{c}"
+
+
def _mutate_vdom(vdom: VdomDict) -> None:
"""Performs any necessary mutations on the VDOM attributes to meet VDOM spec.
@@ -195,7 +263,7 @@ def _mutate_vdom(vdom: VdomDict) -> None:
def _generate_vdom_children(
node: etree._Element, transforms: Iterable[_ModelTransform]
-) -> List[Union[VdomDict, str]]:
+) -> list[VdomDict | str]:
"""Generates a list of VDOM children from an lxml node.
Inserts inner text and/or tail text inbetween VDOM children, if necessary.
@@ -221,5 +289,37 @@ def _hypen_to_camel_case(string: str) -> str:
return first.lower() + remainder.title().replace("-", "")
-class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc]
- """Raised when an HTML document cannot be parsed using strict parsing."""
+def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]:
+ if key == "style":
+ if isinstance(value, dict):
+ value = ";".join(
+ # We lower only to normalize - CSS is case-insensitive:
+ # https://www.w3.org/TR/css-fonts-3/#font-family-casing
+ f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}"
+ for k, v in value.items()
+ )
+ elif (
+ # camel to data-* attributes
+ key.startswith("data")
+ # camel to aria-* attributes
+ or key.startswith("aria")
+ # handle special cases
+ or key in _DASHED_HTML_ATTRS
+ ):
+ key = _CAMEL_CASE_SUB_PATTERN.sub("-", key)
+
+ assert not callable(
+ value
+ ), f"Could not convert callable attribute {key}={value} to HTML"
+
+ # Again, we lower the attribute name only to normalize - HTML is case-insensitive:
+ # http://w3c.github.io/html-reference/documents.html#case-insensitivity
+ return key.lower(), str(value)
+
+
+# Pattern for delimitting camelCase names (e.g. camelCase to camel-case)
+_CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None:
def _use_callable(initial_func: _Func) -> Tuple[_Func, Callable[[_Func], None]]:
- state = hooks.use_state(lambda: initial_func)
- return state[0], lambda new: state[1](lambda old: new)
+ state, set_state = hooks.use_state(lambda: initial_func)
+ return state, lambda new: set_state(lambda old: new)
diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py
new file mode 100644
index 000000000..e575625a2
--- /dev/null
+++ b/tests/test_backend/test__common.py
@@ -0,0 +1,58 @@
+import pytest
+
+from idom import html
+from idom.backend._common import traversal_safe_path, vdom_head_elements_to_html
+
+
+@pytest.mark.parametrize(
+ "bad_path",
+ [
+ "../escaped",
+ "ok/../../escaped",
+ "ok/ok-again/../../ok-yet-again/../../../escaped",
+ ],
+)
+def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path):
+ with pytest.raises(ValueError, match="Unsafe path"):
+ traversal_safe_path(tmp_path, *bad_path.split("/"))
+
+
+@pytest.mark.parametrize(
+ "vdom_in, html_out",
+ [
+ (
+ "example",
+ "example",
+ ),
+ (
+ # We do not modify strings given by user. If given as VDOM we would have
+ # striped this head element, but since provided as string, we leav as-is.
+ "",
+ "",
+ ),
+ (
+ html.head(
+ html.meta({"charset": "utf-8"}),
+ html.title("example"),
+ ),
+ # we strip the head element
+ 'example',
+ ),
+ (
+ html._(
+ html.meta({"charset": "utf-8"}),
+ html.title("example"),
+ ),
+ 'example',
+ ),
+ (
+ [
+ html.meta({"charset": "utf-8"}),
+ html.title("example"),
+ ],
+ 'example',
+ ),
+ ],
+)
+def test_vdom_head_elements_to_html(vdom_in, html_out):
+ assert vdom_head_elements_to_html(vdom_in) == html_out
diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_all.py
similarity index 87%
rename from tests/test_backend/test_common.py
rename to tests/test_backend/test_all.py
index f0856b209..98036cb16 100644
--- a/tests/test_backend/test_common.py
+++ b/tests/test_backend/test_all.py
@@ -5,7 +5,7 @@
import idom
from idom import html
from idom.backend import default as default_implementation
-from idom.backend._urls import PATH_PREFIX
+from idom.backend._common import PATH_PREFIX
from idom.backend.types import BackendImplementation, Connection, Location
from idom.backend.utils import all_implementations
from idom.testing import BackendFixture, DisplayFixture, poll
@@ -155,3 +155,20 @@ def ShowRoute():
# we can't easily narrow this check
assert hook_val.current is not None
+
+
+@pytest.mark.parametrize("imp", all_implementations())
+async def test_customized_head(imp: BackendImplementation, page):
+ custom_title = f"Custom Title for {imp.__name__}"
+
+ @idom.component
+ def sample():
+ return html.h1(f"^ Page title is customized to: '{custom_title}'")
+
+ async with BackendFixture(
+ implementation=imp,
+ options=imp.Options(head=html.title(custom_title)),
+ ) as server:
+ async with DisplayFixture(backend=server, driver=page) as display:
+ await display.show(sample)
+ assert (await display.page.title()) == custom_title
diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py
index b55cdd990..c3cb13613 100644
--- a/tests/test_backend/test_utils.py
+++ b/tests/test_backend/test_utils.py
@@ -8,7 +8,6 @@
from idom.backend import flask as flask_implementation
from idom.backend.utils import find_available_port
from idom.backend.utils import run as sync_run
-from idom.backend.utils import traversal_safe_path
from idom.sample import SampleApp as SampleApp
@@ -45,16 +44,3 @@ async def test_run(page: Page, exit_stack: ExitStack):
await page.goto(url)
await page.wait_for_selector("#sample")
-
-
-@pytest.mark.parametrize(
- "bad_path",
- [
- "../escaped",
- "ok/../../escaped",
- "ok/ok-again/../../ok-yet-again/../../../escaped",
- ],
-)
-def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path):
- with pytest.raises(ValueError, match="Unsafe path"):
- traversal_safe_path(tmp_path, *bad_path.split("/"))
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 861fc315d..405cdce05 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,10 @@
+from html import escape as html_escape
+
import pytest
import idom
-from idom.utils import HTMLParseError, html_to_vdom
+from idom import html
+from idom.utils import HTMLParseError, html_to_vdom, vdom_to_html
def test_basic_ref_behavior():
@@ -149,3 +152,81 @@ def test_html_to_vdom_with_no_parent_node():
}
assert html_to_vdom(source) == expected
+
+
+SOME_OBJECT = object()
+
+
+@pytest.mark.parametrize(
+ "vdom_in, html_out",
+ [
+ (
+ html.div("hello"),
+ "hello
",
+ ),
+ (
+ html.div(SOME_OBJECT),
+ f"{html_escape(str(SOME_OBJECT))}
",
+ ),
+ (
+ html.div({"someAttribute": SOME_OBJECT}),
+ f'',
+ ),
+ (
+ html.div(
+ "hello", html.a({"href": "https://example.com"}, "example"), "world"
+ ),
+ '',
+ ),
+ (
+ html.button({"onClick": lambda event: None}),
+ "",
+ ),
+ (
+ html._("hello ", html._("world")),
+ "hello world",
+ ),
+ (
+ html._(html.div("hello"), html._("world")),
+ "hello
world",
+ ),
+ (
+ html.div({"style": {"backgroundColor": "blue", "marginLeft": "10px"}}),
+ '',
+ ),
+ (
+ html.div({"style": "background-color:blue;margin-left:10px"}),
+ '',
+ ),
+ (
+ html._(
+ html.div("hello"),
+ html.a({"href": "https://example.com"}, "example"),
+ ),
+ 'hello
example',
+ ),
+ (
+ html.div(
+ html._(
+ html.div("hello"),
+ html.a({"href": "https://example.com"}, "example"),
+ ),
+ html.button(),
+ ),
+ '',
+ ),
+ (
+ html.div(
+ {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3}
+ ),
+ '',
+ ),
+ ],
+)
+def test_vdom_to_html(vdom_in, html_out):
+ assert vdom_to_html(vdom_in) == html_out
+
+
+def test_vdom_to_html_error():
+ with pytest.raises(TypeError, match="Expected a VDOM dict"):
+ vdom_to_html({"notVdom": True})