Skip to content

allow users to configure html head #835

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Nov 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/_custom_js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<head>`` element of IDOM's built-in client.
- :pull:`835` - ``vdom_to_html`` utility function.


v0.41.0
Expand Down
8 changes: 2 additions & 6 deletions src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link
rel="icon"
href="public/idom-logo-square-small.svg"
type="image/svg+xml"
/>
<title>IDOM</title>
<!-- we replace this with user-provided head elements -->
{__head__}
</head>
<body>
<div id="app"></div>
Expand Down
3 changes: 2 additions & 1 deletion src/idom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -53,6 +53,7 @@
"use_ref",
"use_scope",
"use_state",
"vdom_to_html",
"vdom",
"web",
]
42 changes: 0 additions & 42 deletions src/idom/backend/_asgi.py

This file was deleted.

130 changes: 130 additions & 0 deletions src/idom/backend/_common.py
Original file line number Diff line number Diff line change
@@ -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 ``<head>`` 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"""
7 changes: 0 additions & 7 deletions src/idom/backend/_urls.py

This file was deleted.

53 changes: 25 additions & 28 deletions src/idom/backend/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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,
Expand All @@ -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}/<path:path>")
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}/<path:path>")
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}/<path:path>")
def send_modules_dir(path: str = "") -> Any:
return send_file(safe_web_modules_dir_path(path))

@api_blueprint.route(f"/{MODULES_PATH.name}/<path:path>")
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("/<path:_>")
def send_client_dir(_: str = "") -> Any:
return send_file(CLIENT_BUILD_DIR / "index.html")
@spa_blueprint.route("/")
@spa_blueprint.route("/<path:_>")
def send_client_dir(_: str = "") -> Any:
return index_html


def _setup_single_view_dispatcher_route(
Expand Down
Loading