Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/reactpy/executors/asgi/pyscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
pyscript_component_html,
pyscript_setup_html,
)
from reactpy.executors.utils import vdom_head_to_html
from reactpy.types import ReactPyConfig, VdomDict
from reactpy.executors.utils import html_noscript_to_html, vdom_head_to_html
from reactpy.types import Component, ReactPyConfig, RootComponentConstructor, VdomDict


class ReactPyCsr(ReactPy):
Expand All @@ -32,6 +32,11 @@ def __init__(
initial: str | VdomDict = "",
http_headers: dict[str, str] | None = None,
html_head: VdomDict | None = None,
html_noscript: str
| Path
| Component
| RootComponentConstructor
| None = "Enable JavaScript to view this site.",
html_lang: str = "en",
**settings: Unpack[ReactPyConfig],
) -> None:
Expand Down Expand Up @@ -59,6 +64,9 @@ def __init__(
commonly used to render a loading animation.
http_headers: Additional headers to include in the HTTP response for the base HTML document.
html_head: Additional head elements to include in the HTML response.
html_noscript: String, Path to an HTML file, or component rendered to HTML
inside a `<noscript>` tag in the HTML body.
If None, then noscript is not rendered.
html_lang: The language of the HTML document.
settings:
Global ReactPy configuration settings that affect behavior and performance. Most settings
Expand All @@ -78,6 +86,7 @@ def __init__(
self.extra_headers = http_headers or {}
self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?")
self.html_head = html_head or html.head()
self.html_noscript = html_noscript
self.html_lang = html_lang

def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: # nocov
Expand All @@ -97,6 +106,7 @@ class ReactPyPyscriptApp(ReactPyApp):
def render_index_html(self) -> None:
"""Process the index.html and store the results in this class."""
head_content = vdom_head_to_html(self.parent.html_head)
noscript = html_noscript_to_html(self.parent.html_noscript)
pyscript_setup = pyscript_setup_html(
extra_py=self.parent.extra_py,
extra_js=self.parent.extra_js,
Expand All @@ -114,6 +124,7 @@ def render_index_html(self) -> None:
f'<html lang="{self.parent.html_lang}">'
f"{head_content}"
"<body>"
f"{noscript}"
f"{pyscript_component}"
"</body>"
"</html>"
Expand Down
18 changes: 17 additions & 1 deletion src/reactpy/executors/asgi/standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import UTC, datetime
from email.utils import formatdate
from logging import getLogger
from pathlib import Path
from typing import Literal, Unpack, cast, overload

from asgi_tools import ResponseHTML
Expand All @@ -24,8 +25,13 @@
AsgiWebsocketScope,
)
from reactpy.executors.pyscript.utils import pyscript_setup_html
from reactpy.executors.utils import server_side_component_html, vdom_head_to_html
from reactpy.executors.utils import (
html_noscript_to_html,
server_side_component_html,
vdom_head_to_html,
)
from reactpy.types import (
Component,
PyScriptOptions,
ReactPyConfig,
RootComponentConstructor,
Expand All @@ -45,6 +51,11 @@ def __init__(
*,
http_headers: dict[str, str] | None = None,
html_head: VdomDict | None = None,
html_noscript: str
| Path
| Component
| RootComponentConstructor
| None = "Enable JavaScript to view this site.",
html_lang: str = "en",
pyscript_setup: bool = False,
pyscript_options: PyScriptOptions | None = None,
Expand All @@ -56,6 +67,8 @@ def __init__(
root_component: The root component to render. This app is typically a single page application.
http_headers: Additional headers to include in the HTTP response for the base HTML document.
html_head: Additional head elements to include in the HTML response.
html_noscript: String, Path to an HTML file, or component rendered to HTML
inside a `<noscript>` tag in the HTML body.
html_lang: The language of the HTML document.
pyscript_setup: Whether to automatically load PyScript within your HTML head.
pyscript_options: Options to configure PyScript behavior.
Expand All @@ -66,6 +79,7 @@ def __init__(
self.extra_headers = http_headers or {}
self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?")
self.html_head = html_head or html.head()
self.html_noscript = html_noscript
self.html_lang = html_lang

if pyscript_setup:
Expand Down Expand Up @@ -229,11 +243,13 @@ async def __call__(

def render_index_html(self) -> None:
"""Process the index.html and store the results in this class."""
noscript = html_noscript_to_html(self.parent.html_noscript)
self._index_html = (
"<!doctype html>"
f'<html lang="{self.parent.html_lang}">'
f"{vdom_head_to_html(self.parent.html_head)}"
"<body>"
f"{noscript}"
f"{server_side_component_html(element_id='app', class_='', component_path='')}"
"</body>"
"</html>"
Expand Down
17 changes: 16 additions & 1 deletion src/reactpy/executors/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from collections.abc import Iterable
from pathlib import Path
from typing import Any

from reactpy._option import Option
Expand All @@ -12,7 +13,7 @@
REACTPY_RECONNECT_MAX_INTERVAL,
REACTPY_RECONNECT_MAX_RETRIES,
)
from reactpy.types import ReactPyConfig, VdomDict
from reactpy.types import Component, ReactPyConfig, RootComponentConstructor, VdomDict
from reactpy.utils import import_dotted_path, reactpy_to_string

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -46,6 +47,20 @@ def vdom_head_to_html(head: VdomDict) -> str:
raise ValueError("Head element must be constructed with `html.head`.")


def html_noscript_to_html(
html_noscript: str | Path | VdomDict | Component | RootComponentConstructor | None,
) -> str:
if html_noscript is None:
return ""
if isinstance(html_noscript, Path):
html_noscript = html_noscript.read_text()
elif callable(html_noscript):
html_noscript = reactpy_to_string(html_noscript())
elif isinstance(html_noscript, dict):
html_noscript = reactpy_to_string(html_noscript)
return f"<noscript>{html_noscript}</noscript>"


def process_settings(settings: ReactPyConfig) -> None:
"""Process the settings and return the final configuration."""
from reactpy import config
Expand Down
154 changes: 154 additions & 0 deletions tests/test_asgi/test_pyscript.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# ruff: noqa: S701
import asyncio
from pathlib import Path

import pytest
from jinja2 import Environment as JinjaEnvironment
from jinja2 import FileSystemLoader as JinjaFileSystemLoader
from requests import request
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.templating import Jinja2Templates

import reactpy
from reactpy import html
from reactpy.executors.asgi.pyscript import ReactPyCsr
from reactpy.testing import BackendFixture, DisplayFixture
from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT


@pytest.fixture(scope="module")
Expand Down Expand Up @@ -98,6 +102,156 @@ def test_bad_file_path():
ReactPyCsr()


async def test_customized_noscript(tmp_path: Path):
noscript_file = tmp_path / "noscript.html"
noscript_file.write_text(
'<p id="noscript-message">Please enable JavaScript.</p>',
encoding="utf-8",
)

app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript=noscript_file,
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
'<noscript><p id="noscript-message">Please enable JavaScript.</p></noscript>'
in response.text
)



async def test_customized_noscript_string():
app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript='<p id="noscript-message">Please enable JavaScript.</p>',
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
'<noscript><p id="noscript-message">Please enable JavaScript.</p></noscript>'
in response.text
)


async def test_customized_noscript_from_file(tmp_path: Path):
noscript_file = tmp_path / "noscript.html"
noscript_file.write_text(
'<p id="noscript-message">Please enable JavaScript.</p>',
encoding="utf-8",
)

app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript=noscript_file,
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
'<noscript><p id="noscript-message">Please enable JavaScript.</p></noscript>'
in response.text
)


async def test_customized_noscript_from_string():
app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript='<p id="noscript-message">Please enable JavaScript.</p>',
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
'<noscript><p id="noscript-message">Please enable JavaScript.</p></noscript>'
in response.text
)


async def test_customized_noscript_from_component():
@reactpy.component
def noscript_message():
return html.p({"id": "noscript-message"}, "Please enable JavaScript.")

app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript=noscript_message,
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
'<noscript><p id="noscript-message">Please enable JavaScript.</p></noscript>'
in response.text
)


async def test_default_noscript_rendered():
app = ReactPyCsr(Path(__file__).parent / "pyscript_components" / "root.py")

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert "<noscript>Enable JavaScript to view this site.</noscript>" in response.text



async def test_noscript_omitted():
app = ReactPyCsr(Path(__file__).parent / "pyscript_components" / "root.py")

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert (
"<noscript>Enable JavaScript to view this site.</noscript>"
in response.text
)


async def test_noscript_disabled():
app = ReactPyCsr(
Path(__file__).parent / "pyscript_components" / "root.py",
html_noscript=None,
)

async with BackendFixture(app) as server:
url = f"http://{server.host}:{server.port}"
response = await asyncio.to_thread(
request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current
)
assert response.status_code == 200
assert "<noscript>" not in response.text


async def test_jinja_template_tag(jinja_display: DisplayFixture):
await jinja_display.goto("/")

Expand Down
Loading
Loading