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
10 changes: 9 additions & 1 deletion src/reactpy/executors/asgi/pyscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
pyscript_component_html,
pyscript_setup_html,
)
from reactpy.executors.utils import vdom_head_to_html
from reactpy.executors.utils import html_noscript_path_to_html, vdom_head_to_html
from reactpy.types import ReactPyConfig, VdomDict


Expand All @@ -32,6 +32,9 @@ def __init__(
initial: str | VdomDict = "",
http_headers: dict[str, str] | None = None,
html_head: VdomDict | None = None,
html_noscript_str_or_path: str
| Path
| None = "Enable JavaScript to view this site.",
html_lang: str = "en",
**settings: Unpack[ReactPyConfig],
) -> None:
Expand Down Expand Up @@ -59,6 +62,8 @@ 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_str_or_path: String or Path to an HTML file whose contents are rendered within 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 +83,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_str_or_path = html_noscript_str_or_path
self.html_lang = html_lang

def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: # nocov
Expand All @@ -97,6 +103,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_path_to_html(self.parent.html_noscript_str_or_path)
pyscript_setup = pyscript_setup_html(
extra_py=self.parent.extra_py,
extra_js=self.parent.extra_js,
Expand All @@ -114,6 +121,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
15 changes: 14 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,7 +25,11 @@
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_path_to_html,
server_side_component_html,
vdom_head_to_html,
)
from reactpy.types import (
PyScriptOptions,
ReactPyConfig,
Expand All @@ -45,6 +50,9 @@ def __init__(
*,
http_headers: dict[str, str] | None = None,
html_head: VdomDict | None = None,
html_noscript_str_or_path: str
| Path
| None = "Enable JavaScript to view this site.",
html_lang: str = "en",
pyscript_setup: bool = False,
pyscript_options: PyScriptOptions | None = None,
Expand All @@ -56,6 +64,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_str_or_path: String or Path to an HTML file whose contents are rendered within 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 +76,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_str_or_path = html_noscript_str_or_path
self.html_lang = html_lang

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

def render_index_html(self) -> None:
"""Process the index.html and store the results in this class."""
noscript = html_noscript_path_to_html(self.parent.html_noscript_str_or_path)
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
9 changes: 9 additions & 0 deletions 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 Down Expand Up @@ -46,6 +47,14 @@ def vdom_head_to_html(head: VdomDict) -> str:
raise ValueError("Head element must be constructed with `html.head`.")


def html_noscript_path_to_html(path_or_body: str | Path | None) -> str:
if path_or_body is None:
return ""
if isinstance(path_or_body, Path):
return f"<noscript>{path_or_body.read_text()}</noscript>"
return f"<noscript>{path_or_body}</noscript>"


def process_settings(settings: ReactPyConfig) -> None:
"""Process the settings and return the final configuration."""
from reactpy import config
Expand Down
129 changes: 129 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,131 @@ 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_str_or_path=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_str_or_path='<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_str_or_path=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_str_or_path='<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_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_str_or_path=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
86 changes: 86 additions & 0 deletions tests/test_asgi/test_standalone.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
from collections.abc import MutableMapping
from pathlib import Path

import pytest
from asgi_tools import ResponseText
Expand Down Expand Up @@ -138,6 +139,91 @@ def sample():
assert (await new_display.page.title()) == custom_title


async def test_customized_noscript_from_file(tmp_path: Path):
@reactpy.component
def sample():
return html.h1("Hello World")

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

app = ReactPy(
sample,
html_noscript_str_or_path=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():
@reactpy.component
def sample():
return html.h1("Hello World")

app = ReactPy(
sample,
html_noscript_str_or_path='<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_noscript_omitted():
@reactpy.component
def sample():
return html.h1("Hello World")

app = ReactPy(sample)

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():
@reactpy.component
def sample():
return html.h1("Hello World")

app = ReactPy(sample, html_noscript_str_or_path=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_head_request():
@reactpy.component
def sample():
Expand Down
Loading
Loading