From d529feda27e761dff6b8bcc9744182205c17026b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 00:34:09 -0700 Subject: [PATCH 01/28] functional pyscript prototype --- src/reactpy_django/pyscript/__init__.py | 0 src/reactpy_django/pyscript/executor.py | 98 +++++++++++++++++++ .../templates/reactpy/pyscript_component.html | 6 ++ src/reactpy_django/templatetags/reactpy.py | 96 +++++++++++++++--- tests/test_app/pyscript/__init__.py | 0 .../test_app/pyscript/components/__init__.py | 0 tests/test_app/pyscript/components/counter.py | 15 +++ .../pyscript/components/hello_world.py | 6 ++ tests/test_app/pyscript/urls.py | 7 ++ tests/test_app/pyscript/views.py | 5 + tests/test_app/templates/pyscript.html | 22 +++++ tests/test_app/urls.py | 2 + 12 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 src/reactpy_django/pyscript/__init__.py create mode 100644 src/reactpy_django/pyscript/executor.py create mode 100644 src/reactpy_django/templates/reactpy/pyscript_component.html create mode 100644 tests/test_app/pyscript/__init__.py create mode 100644 tests/test_app/pyscript/components/__init__.py create mode 100644 tests/test_app/pyscript/components/counter.py create mode 100644 tests/test_app/pyscript/components/hello_world.py create mode 100644 tests/test_app/pyscript/urls.py create mode 100644 tests/test_app/pyscript/views.py create mode 100644 tests/test_app/templates/pyscript.html diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/pyscript/executor.py b/src/reactpy_django/pyscript/executor.py new file mode 100644 index 00000000..2d403441 --- /dev/null +++ b/src/reactpy_django/pyscript/executor.py @@ -0,0 +1,98 @@ +"""Code within this module is designed to be run directly by PyScript, and is not +intended to be run in a Django environment. + +Our template tag performs string substitutions to turn this file into valid PyScript.""" + +import asyncio + +import js +from jsonpointer import set_pointer +from pyodide.ffi.wrappers import add_event_listener +from reactpy.core.layout import Layout + + +# User component is inserted below by regex replacement +def user_workspace_UUID(): + """Encapsulate the user's code with a completely unique function (workspace) + to prevent overlapping imports and variable names between different components.""" + + def root(): ... + + return root() + + +# ReactPy layout rendering starts here +class LayoutManagerUUID: + """Encapsulate an entire layout manager with a completely unique class to prevent + rendering bugs caused by the PyScript global interpreter.""" + + @staticmethod + def apply_update(update, root_model): + if update["path"]: + set_pointer(root_model, update["path"], update["model"]) + else: + root_model.update(update["model"]) + + def render_model(self, layout, model): + container = js.document.getElementById("UUID") + container.innerHTML = "" + self._render_model(layout, container, model) + + def _render_model(self, layout, parent, model): + if isinstance(model, str): + parent.appendChild(js.document.createTextNode(model)) + elif isinstance(model, dict): + if not model["tagName"]: + for child in model.get("children", []): + self._render_model(layout, parent, child) + return + tag = model["tagName"] + attributes = model.get("attributes", {}) + children = model.get("children", []) + element = js.document.createElement(tag) + for key, value in attributes.items(): + if key == "style": + for style_key, style_value in value.items(): + setattr(element.style, style_key, style_value) + else: + element.setAttribute(key, value) + for event_name, event_handler_model in model.get( + "eventHandlers", {} + ).items(): + self._create_event_handler( + layout, element, event_name, event_handler_model + ) + for child in children: + self._render_model(layout, element, child) + parent.appendChild(element) + else: + raise ValueError(f"Unknown model type: {type(model)}") + + @staticmethod + def _create_event_handler(layout, element, event_name, event_handler_model): + target = event_handler_model["target"] + + def event_handler(*args): + asyncio.create_task( + layout.deliver( + { + "type": "layout-event", + "target": target, + "data": args, + } + ) + ) + + event_name = event_name.lstrip("on_").lower().replace("_", "") + add_event_listener(element, event_name, event_handler) + + async def run(self): + root_model = {} + async with Layout(user_workspace_UUID()) as layout: + while True: + update = await layout.render() + self.apply_update(update, root_model) + self.render_model(layout, root_model) + + +asyncio.create_task(LayoutManagerUUID().run()) diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html new file mode 100644 index 00000000..b37ad7d1 --- /dev/null +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -0,0 +1,6 @@ +{% load static %} + + +{% if reactpy_class %}
{{reactpy_initial_html}}
{% endif %} +{% if not reactpy_class %}
{{reactpy_initial_html}}
{% endif %} +{{ reactpy_executor }} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index e33d8387..0ceb1a3f 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,18 +1,24 @@ from __future__ import annotations +import textwrap from logging import getLogger +from pathlib import Path from uuid import uuid4 import dill as pickle +import jsonpointer +import orjson +import reactpy from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location -from reactpy.core.types import ComponentConstructor +from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict from reactpy.utils import vdom_to_html -from reactpy_django import config, models +from reactpy_django import config as reactpy_config +from reactpy_django import models, pyscript from reactpy_django.exceptions import ( ComponentCarrierError, ComponentDoesNotExistError, @@ -30,6 +36,10 @@ register = template.Library() _logger = getLogger(__name__) +pyscript_template = (Path(pyscript.__file__).parent / "executor.py").read_text( + encoding="utf-8" +) + @register.inclusion_tag("reactpy/component.html", takes_context=True) def component( @@ -37,7 +47,7 @@ def component( dotted_path: str, *args, host: str | None = None, - prerender: str = str(config.REACTPY_PRERENDER), + prerender: str = str(reactpy_config.REACTPY_PRERENDER), offline: str = "", **kwargs, ): @@ -73,7 +83,11 @@ def component( perceived_host = (request.get_host() if request else "").strip("/") host = ( host - or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "") + or ( + next(reactpy_config.REACTPY_DEFAULT_HOSTS) + if reactpy_config.REACTPY_DEFAULT_HOSTS + else "" + ) ).strip("/") is_local = not host or host.startswith(perceived_host) uuid = str(uuid4()) @@ -84,7 +98,7 @@ def component( _offline_html = "" # Validate the host - if host and config.REACTPY_DEBUG_MODE: + if host and reactpy_config.REACTPY_DEBUG_MODE: try: validate_host(host) except InvalidHostError as e: @@ -92,14 +106,14 @@ def component( # Fetch the component if is_local: - user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path) + user_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path) if not user_component: msg = f"Component '{dotted_path}' is not registered as a root component. " _logger.error(msg) return failure_context(dotted_path, ComponentDoesNotExistError(msg)) # Validate the component args & kwargs - if is_local and config.REACTPY_DEBUG_MODE: + if is_local and reactpy_config.REACTPY_DEBUG_MODE: try: validate_component_args(user_component, *args, **kwargs) except ComponentParamError as e: @@ -140,7 +154,7 @@ def component( # Fetch the offline component's HTML, if requested if offline: - offline_component = config.REACTPY_REGISTERED_COMPONENTS.get(offline) + offline_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(offline) if not offline_component: msg = f"Cannot render offline component '{offline}'. It is not registered as a component." _logger.error(msg) @@ -159,13 +173,13 @@ def component( "reactpy_class": class_, "reactpy_uuid": uuid, "reactpy_host": host or perceived_host, - "reactpy_url_prefix": config.REACTPY_URL_PREFIX, + "reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX, "reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, - "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL, - "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, - "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, - "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES, + "reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL, + "reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL, + "reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + "reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES, "reactpy_prerender_html": _prerender_html, "reactpy_offline_html": _offline_html, } @@ -174,7 +188,7 @@ def component( def failure_context(dotted_path: str, error: Exception): return { "reactpy_failure": True, - "reactpy_debug_mode": config.REACTPY_DEBUG_MODE, + "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, "reactpy_dotted_path": dotted_path, "reactpy_error": type(error).__name__, } @@ -219,3 +233,57 @@ def prerender_component( vdom_tree = layout.render()["model"] return vdom_to_html(vdom_tree) + + +# TODO: Add micropython support +@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) +def pyscript_component( + context: template.RequestContext, + file_path: str, + *extra_packages: str, + initial: str | VdomDict | ComponentType = "", + config: str | dict = "", + root: str = "root", +): + uuid = uuid4().hex + request: HttpRequest | None = context.get("request") + pyscript_config = { + "packages": [ + f"reactpy=={reactpy.__version__}", + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + *extra_packages, + ] + } + if config and isinstance(config, str): + pyscript_config.update(orjson.loads(config)) + elif isinstance(config, dict): + pyscript_config.update(config) + + # Convert the user provided initial HTML to a string, if needed + if isinstance(initial, dict): + initial = vdom_to_html(initial) + elif hasattr(initial, "render"): + if not request: + raise ValueError( + "Cannot render a component without a HTTP request. Are you missing the request " + "context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?" + ) + initial = prerender_component(initial, [], {}, uuid, request) + + # Create a valid PyScript executor by replacing the template values + executor = pyscript_template.replace("UUID", uuid) + executor = executor.replace("return root()", f"return {root}()") + + # Insert the user code into the template + user_code = Path(file_path).read_text(encoding="utf-8") + user_code = user_code.strip().replace("\t", " ") # Normalize the code text + user_code = textwrap.indent(user_code, " ") # Add indentation to match template + executor = executor.replace(" def root(): ...", user_code) + + return { + "reactpy_executor": executor, + "reactpy_uuid": uuid, + "reactpy_initial_html": initial, + "reactpy_config": orjson.dumps(pyscript_config).decode(), + } diff --git a/tests/test_app/pyscript/__init__.py b/tests/test_app/pyscript/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/pyscript/components/__init__.py b/tests/test_app/pyscript/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py new file mode 100644 index 00000000..f7717181 --- /dev/null +++ b/tests/test_app/pyscript/components/counter.py @@ -0,0 +1,15 @@ +from reactpy import component, html, use_state + + +@component +def root(): + value, set_value = use_state(0) + return html.article( + html.div( + {"class": "grid"}, + html.button({"on_click": lambda event: set_value(value + 1)}, "+"), + html.button({"on_click": lambda event: set_value(value - 1)}, "-"), + ), + "Current value", + html.pre({"style": {"font-style": "bold"}}, str(value)), + ) diff --git a/tests/test_app/pyscript/components/hello_world.py b/tests/test_app/pyscript/components/hello_world.py new file mode 100644 index 00000000..8081c028 --- /dev/null +++ b/tests/test_app/pyscript/components/hello_world.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def root(): + return html.div("hello world") diff --git a/tests/test_app/pyscript/urls.py b/tests/test_app/pyscript/urls.py new file mode 100644 index 00000000..e71c18e5 --- /dev/null +++ b/tests/test_app/pyscript/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from test_app.pyscript.views import pyscript + +urlpatterns = [ + re_path(r"^pyscript/(?P.*)/?$", pyscript), +] diff --git a/tests/test_app/pyscript/views.py b/tests/test_app/pyscript/views.py new file mode 100644 index 00000000..f4891be1 --- /dev/null +++ b/tests/test_app/pyscript/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def pyscript(request, path=None): + return render(request, "pyscript.html", {}) diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html new file mode 100644 index 00000000..81a95817 --- /dev/null +++ b/tests/test_app/templates/pyscript.html @@ -0,0 +1,22 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy PyScript Test Page

+
+ {% pyscript_component "./test_app/pyscript/components/hello_world.py" %} +
+ {% pyscript_component "./test_app/pyscript/components/counter.py" %} +
+ + + diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 05acb163..070b74f1 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -14,6 +14,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import include, path @@ -30,6 +31,7 @@ path("", include("test_app.prerender.urls")), path("", include("test_app.performance.urls")), path("", include("test_app.router.urls")), + path("", include("test_app.pyscript.urls")), path("", include("test_app.offline.urls")), path("", include("test_app.channel_layers.urls")), path("reactpy/", include("reactpy_django.http.urls")), From bdf196080a41bcd4d4f8454107733a20f341b68d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 15:46:55 -0700 Subject: [PATCH 02/28] first attempt at pyscript components with server-side parent components --- src/reactpy_django/components.py | 80 +++++++++++++-- src/reactpy_django/pyscript/__init__.py | 0 .../executor.py => pyscript_template.py} | 26 ++--- .../templates/reactpy/pyscript_component.html | 5 +- src/reactpy_django/templatetags/reactpy.py | 85 +++------------- src/reactpy_django/utils.py | 98 +++++++++++++++++++ tests/test_app/components.py | 12 +-- tests/test_app/pyscript/components/child.py | 10 ++ .../pyscript/components/child_embed.py | 16 +++ tests/test_app/pyscript/components/counter.py | 2 +- tests/test_app/templates/pyscript.html | 2 + 11 files changed, 233 insertions(+), 103 deletions(-) delete mode 100644 src/reactpy_django/pyscript/__init__.py rename src/reactpy_django/{pyscript/executor.py => pyscript_template.py} (79%) create mode 100644 tests/test_app/pyscript/components/child.py create mode 100644 tests/test_app/pyscript/components/child_embed.py diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 75b0c321..dad3f576 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -4,18 +4,29 @@ import os from typing import Any, Callable, Sequence, Union, cast, overload from urllib.parse import urlencode +from uuid import uuid4 from warnings import warn +import orjson from django.contrib.staticfiles.finders import find from django.core.cache import caches from django.http import HttpRequest +from django.templatetags.static import static from django.urls import reverse from django.views import View from reactpy import component, hooks, html, utils -from reactpy.types import Key, VdomDict +from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError -from reactpy_django.utils import generate_obj_name, import_module, render_view +from reactpy_django.utils import ( + PYSCRIPT_TAG, + extend_pyscript_config, + generate_obj_name, + import_module, + render_pyscript_template, + render_view, + vdom_or_component_to_string, +) # Type hints for: @@ -27,8 +38,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: - ... +) -> Any: ... # Type hints for: @@ -39,8 +49,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Callable[[Callable], Any]: - ... +) -> Callable[[Callable], Any]: ... def view_to_component( @@ -148,6 +157,24 @@ def django_js(static_path: str, key: Key | None = None): return _django_js(static_path=static_path, key=key) +def python_to_pyscript( + file_path: str, + *extra_packages: str, + extra_props: dict[str, Any] | None = None, + initial: str | VdomDict | ComponentType = "", + config: str | dict = "", + root: str = "root", +): + return _python_to_pyscript( + file_path, + *extra_packages, + extra_props=extra_props, + initial=initial, + config=config, + root=root, + ) + + @component def _view_to_component( view: Callable | View | str, @@ -284,3 +311,44 @@ def _cached_static_contents(static_path: str) -> str: ) return file_contents + + +@component +def _python_to_pyscript( + file_path: str, + *extra_packages: str, + extra_props: dict[str, Any] | None = None, + initial: str | VdomDict | ComponentType = "", + config: str | dict = "", + root: str = "root", +): + uuid = uuid4().hex.replace("-", "") + initial = vdom_or_component_to_string(initial, uuid=uuid) + executor = render_pyscript_template(file_path, uuid, root) + new_config = extend_pyscript_config(config, extra_packages) + + return html.div( + html.link( + {"rel": "stylesheet", "href": static("reactpy_django/pyscript/core.css")} + ), + html.script( + { + "type": "module", + "src": static( + "reactpy_django/pyscript/core.js", + ), + } + ), + html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial), + PYSCRIPT_TAG( + { + "async": "", + "config": orjson.dumps(new_config).decode(), + "id": f"script-{uuid}", + }, + executor, + ), + html.script( + f"if (document.querySelector('#pyscript-{uuid}') && document.querySelector('#pyscript-{uuid}').childElementCount != 0 && document.querySelector('#script-{uuid}')) document.querySelector('#script-{uuid}').remove();" + ), + ) diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/reactpy_django/pyscript/executor.py b/src/reactpy_django/pyscript_template.py similarity index 79% rename from src/reactpy_django/pyscript/executor.py rename to src/reactpy_django/pyscript_template.py index 2d403441..7374e2da 100644 --- a/src/reactpy_django/pyscript/executor.py +++ b/src/reactpy_django/pyscript_template.py @@ -33,18 +33,18 @@ def apply_update(update, root_model): else: root_model.update(update["model"]) - def render_model(self, layout, model): - container = js.document.getElementById("UUID") + def render(self, layout, model): + container = js.document.getElementById("pyscript-UUID") container.innerHTML = "" - self._render_model(layout, container, model) + self.build_element_tree(layout, container, model) - def _render_model(self, layout, parent, model): + def build_element_tree(self, layout, parent, model): if isinstance(model, str): parent.appendChild(js.document.createTextNode(model)) elif isinstance(model, dict): if not model["tagName"]: for child in model.get("children", []): - self._render_model(layout, parent, child) + self.build_element_tree(layout, parent, child) return tag = model["tagName"] attributes = model.get("attributes", {}) @@ -59,28 +59,22 @@ def _render_model(self, layout, parent, model): for event_name, event_handler_model in model.get( "eventHandlers", {} ).items(): - self._create_event_handler( + self.create_event_handler( layout, element, event_name, event_handler_model ) for child in children: - self._render_model(layout, element, child) + self.build_element_tree(layout, element, child) parent.appendChild(element) else: raise ValueError(f"Unknown model type: {type(model)}") @staticmethod - def _create_event_handler(layout, element, event_name, event_handler_model): + def create_event_handler(layout, element, event_name, event_handler_model): target = event_handler_model["target"] def event_handler(*args): asyncio.create_task( - layout.deliver( - { - "type": "layout-event", - "target": target, - "data": args, - } - ) + layout.deliver({"type": "layout-event", "target": target, "data": args}) ) event_name = event_name.lstrip("on_").lower().replace("_", "") @@ -92,7 +86,7 @@ async def run(self): while True: update = await layout.render() self.apply_update(update, root_model) - self.render_model(layout, root_model) + self.render(layout, root_model) asyncio.create_task(LayoutManagerUUID().run()) diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html index b37ad7d1..7dd7ab17 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_component.html +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -1,6 +1,7 @@ {% load static %} -{% if reactpy_class %}
{{reactpy_initial_html}}
{% endif %} -{% if not reactpy_class %}
{{reactpy_initial_html}}
{% endif %} +{% if reactpy_class %}
{{reactpy_initial_html}}
+{% endif %} +{% if not reactpy_class %}
{{reactpy_initial_html}}
{% endif %} {{ reactpy_executor }} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 0ceb1a3f..7e27afa5 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,24 +1,17 @@ from __future__ import annotations -import textwrap from logging import getLogger -from pathlib import Path from uuid import uuid4 import dill as pickle -import jsonpointer import orjson -import reactpy from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.types import Connection, Location from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict -from reactpy.utils import vdom_to_html from reactpy_django import config as reactpy_config -from reactpy_django import models, pyscript +from reactpy_django import models from reactpy_django.exceptions import ( ComponentCarrierError, ComponentDoesNotExistError, @@ -27,7 +20,14 @@ OfflineComponentMissing, ) from reactpy_django.types import ComponentParams -from reactpy_django.utils import SyncLayout, strtobool, validate_component_args +from reactpy_django.utils import ( + extend_pyscript_config, + prerender_component, + render_pyscript_template, + strtobool, + validate_component_args, + vdom_or_component_to_string, +) try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") @@ -36,10 +36,6 @@ register = template.Library() _logger = getLogger(__name__) -pyscript_template = (Path(pyscript.__file__).parent / "executor.py").read_text( - encoding="utf-8" -) - @register.inclusion_tag("reactpy/component.html", takes_context=True) def component( @@ -211,31 +207,6 @@ def validate_host(host: str): raise InvalidHostError(msg) -def prerender_component( - user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest -): - search = request.GET.urlencode() - scope = getattr(request, "scope", {}) - scope["reactpy"] = {"id": str(uuid)} - - with SyncLayout( - ConnectionContext( - user_component(*args, **kwargs), - value=Connection( - scope=scope, - location=Location( - pathname=request.path, search=f"?{search}" if search else "" - ), - carrier=request, - ), - ) - ) as layout: - vdom_tree = layout.render()["model"] - - return vdom_to_html(vdom_tree) - - -# TODO: Add micropython support @register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) def pyscript_component( context: template.RequestContext, @@ -247,43 +218,13 @@ def pyscript_component( ): uuid = uuid4().hex request: HttpRequest | None = context.get("request") - pyscript_config = { - "packages": [ - f"reactpy=={reactpy.__version__}", - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - *extra_packages, - ] - } - if config and isinstance(config, str): - pyscript_config.update(orjson.loads(config)) - elif isinstance(config, dict): - pyscript_config.update(config) - - # Convert the user provided initial HTML to a string, if needed - if isinstance(initial, dict): - initial = vdom_to_html(initial) - elif hasattr(initial, "render"): - if not request: - raise ValueError( - "Cannot render a component without a HTTP request. Are you missing the request " - "context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?" - ) - initial = prerender_component(initial, [], {}, uuid, request) - - # Create a valid PyScript executor by replacing the template values - executor = pyscript_template.replace("UUID", uuid) - executor = executor.replace("return root()", f"return {root}()") - - # Insert the user code into the template - user_code = Path(file_path).read_text(encoding="utf-8") - user_code = user_code.strip().replace("\t", " ") # Normalize the code text - user_code = textwrap.indent(user_code, " ") # Add indentation to match template - executor = executor.replace(" def root(): ...", user_code) + initial = vdom_or_component_to_string(initial, request=request, uuid=uuid) + executor = render_pyscript_template(file_path, uuid, root) + new_config = extend_pyscript_config(config, extra_packages) return { "reactpy_executor": executor, "reactpy_uuid": uuid, "reactpy_initial_html": initial, - "reactpy_config": orjson.dumps(pyscript_config).decode(), + "reactpy_config": orjson.dumps(new_config).decode(), } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 73538ad7..f2fde3dd 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -5,11 +5,18 @@ import logging import os import re +import textwrap from asyncio import iscoroutinefunction +from copy import deepcopy from fnmatch import fnmatch from importlib import import_module +from pathlib import Path from typing import Any, Callable, Sequence +from uuid import UUID, uuid4 +import jsonpointer +import orjson +import reactpy from asgiref.sync import async_to_sync from channels.db import database_sync_to_async from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects @@ -19,7 +26,11 @@ from django.template import engines from django.utils.encoding import smart_str from django.views import View +from reactpy import vdom_to_html +from reactpy.backend.hooks import ConnectionContext +from reactpy.backend.types import Connection, Location from reactpy.core.layout import Layout +from reactpy.core.vdom import make_vdom_constructor from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ( @@ -44,6 +55,17 @@ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) +PYSCRIPT_TEMPLATE = (Path(__file__).parent / "pyscript_template.py").read_text( + encoding="utf-8" +) +PYSCRIPT_TAG = make_vdom_constructor("py-script") +PYSCRIPT_DEFAULT_CONFIG = { + "packages": [ + f"reactpy=={reactpy.__version__}", + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ] +} async def render_view( @@ -381,3 +403,79 @@ def strtobool(val): return 0 else: raise ValueError(f"invalid truth value {val}") + + +def prerender_component( + user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest +) -> str: + """Prerenders a ReactPy component and returns the HTML string.""" + search = request.GET.urlencode() + scope = getattr(request, "scope", {}) + scope["reactpy"] = {"id": str(uuid)} + + with SyncLayout( + ConnectionContext( + user_component(*args, **kwargs), + value=Connection( + scope=scope, + location=Location( + pathname=request.path, search=f"?{search}" if search else "" + ), + carrier=request, + ), + ) + ) as layout: + vdom_tree = layout.render()["model"] + + return vdom_to_html(vdom_tree) + + +def vdom_or_component_to_string( + vdom_or_component: Any, request: HttpRequest | None = None, uuid: UUID | None = None +) -> str: + """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be + automatically returned.""" + if isinstance(vdom_or_component, dict): + return vdom_to_html(vdom_or_component) + + if hasattr(vdom_or_component, "render"): + if not request: + request = HttpRequest() + request.method = "GET" + if not uuid: + uuid = uuid4().hex + return prerender_component(vdom_or_component, [], {}, uuid, request) + + if isinstance(vdom_or_component, str): + return vdom_or_component + + raise ValueError( + f"Invalid type for vdom_or_component: {type(vdom_or_component)}. " + "Expected a VdomDict, component, or string." + ) + + +def render_pyscript_template(file_path: str, uuid: str, root: str): + """Inserts the user's code into our PyScript template using pattern matching.""" + # Create a valid PyScript executor by replacing the template values + executor = PYSCRIPT_TEMPLATE.replace("UUID", uuid) + executor = executor.replace("return root()", f"return {root}()") + + # Insert the user code into the template + user_code = Path(file_path).read_text(encoding="utf-8") + user_code = user_code.strip().replace("\t", " ") # Normalize the code text + user_code = textwrap.indent(user_code, " ") # Add indentation to match template + executor = executor.replace(" def root(): ...", user_code) + + return executor + + +def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> dict: + """Extends the default PyScript configuration with user configuration.""" + pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) + pyscript_config["packages"].extend(extra_packages) + if config and isinstance(config, str): + pyscript_config.update(orjson.loads(config)) + elif isinstance(config, dict): + pyscript_config.update(config) + return pyscript_config diff --git a/tests/test_app/components.py b/tests/test_app/components.py index fe6df2f0..69b1541c 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -724,10 +724,10 @@ async def on_submit(event): ), }, html.div("use_user_data"), - html.button({"class": "login-1", "on_click": login_user1}, "Login 1"), - html.button({"class": "login-2", "on_click": login_user2}, "Login 2"), - html.button({"class": "logout", "on_click": logout_user}, "Logout"), - html.button({"class": "clear", "on_click": clear_data}, "Clear Data"), + html.button({"className": "login-1", "on_click": login_user1}, "Login 1"), + html.button({"className": "login-2", "on_click": login_user2}, "Login 2"), + html.button({"className": "logout", "on_click": logout_user}, "Logout"), + html.button({"className": "clear", "on_click": clear_data}, "Clear Data"), html.div(f"User: {current_user}"), html.div(f"Data: {user_data_query.data}"), html.div( @@ -792,8 +792,8 @@ async def on_submit(event): ), }, html.div("use_user_data_with_default"), - html.button({"class": "login-3", "on_click": login_user3}, "Login 3"), - html.button({"class": "clear", "on_click": clear_data}, "Clear Data"), + html.button({"className": "login-3", "on_click": login_user3}, "Login 3"), + html.button({"className": "clear", "on_click": clear_data}, "Clear Data"), html.div(f"User: {current_user}"), html.div(f"Data: {user_data_query.data}"), html.div( diff --git a/tests/test_app/pyscript/components/child.py b/tests/test_app/pyscript/components/child.py new file mode 100644 index 00000000..6edf74a5 --- /dev/null +++ b/tests/test_app/pyscript/components/child.py @@ -0,0 +1,10 @@ +from reactpy import component, html +from reactpy_django.components import python_to_pyscript + + +@component +def embed(): + return html.div( + {"className": "embeddable"}, + python_to_pyscript("./test_app/pyscript/components/child_embed.py"), + ) diff --git a/tests/test_app/pyscript/components/child_embed.py b/tests/test_app/pyscript/components/child_embed.py new file mode 100644 index 00000000..3b2a0fbb --- /dev/null +++ b/tests/test_app/pyscript/components/child_embed.py @@ -0,0 +1,16 @@ +from reactpy import component, html, use_state + + +@component +def root(): + value, set_value = use_state(0) + return html.article( + "This was embedded via a server-side component.", + html.div( + {"className": "grid"}, + html.button({"on_click": lambda event: set_value(value + 1)}, "+"), + html.button({"on_click": lambda event: set_value(value - 1)}, "-"), + ), + "Current value", + html.pre({"style": {"font-style": "bold"}}, str(value)), + ) diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py index f7717181..8d024e17 100644 --- a/tests/test_app/pyscript/components/counter.py +++ b/tests/test_app/pyscript/components/counter.py @@ -6,7 +6,7 @@ def root(): value, set_value = use_state(0) return html.article( html.div( - {"class": "grid"}, + {"className": "grid"}, html.button({"on_click": lambda event: set_value(value + 1)}, "+"), html.button({"on_click": lambda event: set_value(value - 1)}, "-"), ), diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 81a95817..065ad414 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -17,6 +17,8 @@

ReactPy PyScript Test Page


{% pyscript_component "./test_app/pyscript/components/counter.py" %}
+ {% component "test_app.pyscript.components.child.embed" %} +
From 260ca3d6b65f83f83dbe5fb1aabc45c15ecae0cd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 17:13:42 -0700 Subject: [PATCH 03/28] fully functional server-side parent component --- src/reactpy_django/components.py | 28 ++++++------------- src/reactpy_django/pyscript/__init__.py | 0 .../component_template.py} | 4 ++- .../templates/reactpy/pyscript_component.html | 3 -- .../reactpy/pyscript_static_files.html | 4 +++ src/reactpy_django/templatetags/reactpy.py | 5 ++++ src/reactpy_django/utils.py | 8 +++--- tests/test_app/pyscript/components/child.py | 25 ++++++++++++++++- tests/test_app/templates/pyscript.html | 3 ++ 9 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 src/reactpy_django/pyscript/__init__.py rename src/reactpy_django/{pyscript_template.py => pyscript/component_template.py} (94%) create mode 100644 src/reactpy_django/templates/reactpy/pyscript_static_files.html diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index dad3f576..deea78af 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -322,33 +322,23 @@ def _python_to_pyscript( config: str | dict = "", root: str = "root", ): + rendered, set_rendered = hooks.use_state(False) uuid = uuid4().hex.replace("-", "") initial = vdom_or_component_to_string(initial, uuid=uuid) executor = render_pyscript_template(file_path, uuid, root) new_config = extend_pyscript_config(config, extra_packages) + if not rendered: + # FIXME: This is needed to properly kill off any previous PyScript instances + # such as when a component is re-rendered due to WebSocket disconnection. + # There may be a better way to do this, but it's not clear at the moment + set_rendered(True) + return None + return html.div( - html.link( - {"rel": "stylesheet", "href": static("reactpy_django/pyscript/core.css")} - ), - html.script( - { - "type": "module", - "src": static( - "reactpy_django/pyscript/core.js", - ), - } - ), html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial), PYSCRIPT_TAG( - { - "async": "", - "config": orjson.dumps(new_config).decode(), - "id": f"script-{uuid}", - }, + {"async": "", "config": orjson.dumps(new_config).decode()}, executor, ), - html.script( - f"if (document.querySelector('#pyscript-{uuid}') && document.querySelector('#pyscript-{uuid}').childElementCount != 0 && document.querySelector('#script-{uuid}')) document.querySelector('#script-{uuid}').remove();" - ), ) diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/pyscript_template.py b/src/reactpy_django/pyscript/component_template.py similarity index 94% rename from src/reactpy_django/pyscript_template.py rename to src/reactpy_django/pyscript/component_template.py index 7374e2da..2ab3fc90 100644 --- a/src/reactpy_django/pyscript_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -89,4 +89,6 @@ async def run(self): self.render(layout, root_model) -asyncio.create_task(LayoutManagerUUID().run()) +# PyScript allows top-level await, which allows us to not throw errors on components +# that terminate early (such as hook-less components) +await LayoutManagerUUID().run() # noqa: F704 diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html index 7dd7ab17..ed8eef98 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_component.html +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -1,6 +1,3 @@ -{% load static %} - - {% if reactpy_class %}
{{reactpy_initial_html}}
{% endif %} {% if not reactpy_class %}
{{reactpy_initial_html}}
{% endif %} diff --git a/src/reactpy_django/templates/reactpy/pyscript_static_files.html b/src/reactpy_django/templates/reactpy/pyscript_static_files.html new file mode 100644 index 00000000..9051fd65 --- /dev/null +++ b/src/reactpy_django/templates/reactpy/pyscript_static_files.html @@ -0,0 +1,4 @@ +{% load static %} + + + diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 7e27afa5..02cbb419 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -228,3 +228,8 @@ def pyscript_component( "reactpy_initial_html": initial, "reactpy_config": orjson.dumps(new_config).decode(), } + + +@register.inclusion_tag("reactpy/pyscript_static_files.html") +def pyscript_static_files(): + return {} diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index f2fde3dd..b621c51d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -55,9 +55,9 @@ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) -PYSCRIPT_TEMPLATE = (Path(__file__).parent / "pyscript_template.py").read_text( - encoding="utf-8" -) +PYSCRIPT_COMPONENT_TEMPLATE = ( + Path(__file__).parent / "pyscript" / "component_template.py" +).read_text(encoding="utf-8") PYSCRIPT_TAG = make_vdom_constructor("py-script") PYSCRIPT_DEFAULT_CONFIG = { "packages": [ @@ -458,7 +458,7 @@ def vdom_or_component_to_string( def render_pyscript_template(file_path: str, uuid: str, root: str): """Inserts the user's code into our PyScript template using pattern matching.""" # Create a valid PyScript executor by replacing the template values - executor = PYSCRIPT_TEMPLATE.replace("UUID", uuid) + executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) executor = executor.replace("return root()", f"return {root}()") # Insert the user code into the template diff --git a/tests/test_app/pyscript/components/child.py b/tests/test_app/pyscript/components/child.py index 6edf74a5..7daed4cb 100644 --- a/tests/test_app/pyscript/components/child.py +++ b/tests/test_app/pyscript/components/child.py @@ -1,4 +1,4 @@ -from reactpy import component, html +from reactpy import component, html, use_state from reactpy_django.components import python_to_pyscript @@ -8,3 +8,26 @@ def embed(): {"className": "embeddable"}, python_to_pyscript("./test_app/pyscript/components/child_embed.py"), ) + + +@component +def toggeable_embed(): + state, set_state = use_state(False) + + if not state: + return html.div( + {"className": "embeddable"}, + html.button( + {"onClick": lambda x: set_state(not state)}, + "Click to show/hide", + ), + ) + + return html.div( + {"className": "embeddable"}, + html.button( + {"onClick": lambda x: set_state(not state)}, + "Click to show/hide", + ), + python_to_pyscript("./test_app/pyscript/components/child_embed.py"), + ) diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 065ad414..81ee1fe7 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -8,6 +8,7 @@ ReactPy + {% pyscript_static_files %} @@ -19,6 +20,8 @@

ReactPy PyScript Test Page


{% component "test_app.pyscript.components.child.embed" %}
+ {% component "test_app.pyscript.components.child.toggeable_embed" %} +
From 027d2a19376f93e900e5d1c086fd1b154ae9323d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 17:20:23 -0700 Subject: [PATCH 04/28] add pyscript tag to the user API --- src/reactpy_django/__init__.py | 12 +++++++++++- src/reactpy_django/components.py | 5 ++--- src/reactpy_django/html.py | 3 +++ src/reactpy_django/utils.py | 2 -- 4 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 src/reactpy_django/html.py diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 8598ed0c..0bbff9d1 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -2,7 +2,16 @@ import nest_asyncio -from reactpy_django import checks, components, decorators, hooks, router, types, utils +from reactpy_django import ( + checks, + components, + decorators, + hooks, + html, + router, + types, + utils, +) from reactpy_django.websocket.paths import ( REACTPY_WEBSOCKET_PATH, REACTPY_WEBSOCKET_ROUTE, @@ -12,6 +21,7 @@ __all__ = [ "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE", + "html", "hooks", "components", "decorators", diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index deea78af..188b656e 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -11,15 +11,14 @@ from django.contrib.staticfiles.finders import find from django.core.cache import caches from django.http import HttpRequest -from django.templatetags.static import static from django.urls import reverse from django.views import View from reactpy import component, hooks, html, utils from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError +from reactpy_django.html import pyscript from reactpy_django.utils import ( - PYSCRIPT_TAG, extend_pyscript_config, generate_obj_name, import_module, @@ -337,7 +336,7 @@ def _python_to_pyscript( return html.div( html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial), - PYSCRIPT_TAG( + pyscript( {"async": "", "config": orjson.dumps(new_config).decode()}, executor, ), diff --git a/src/reactpy_django/html.py b/src/reactpy_django/html.py new file mode 100644 index 00000000..d35daf43 --- /dev/null +++ b/src/reactpy_django/html.py @@ -0,0 +1,3 @@ +from reactpy.core.vdom import make_vdom_constructor + +pyscript = make_vdom_constructor("py-script") diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index b621c51d..3c38019a 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -30,7 +30,6 @@ from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location from reactpy.core.layout import Layout -from reactpy.core.vdom import make_vdom_constructor from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ( @@ -58,7 +57,6 @@ PYSCRIPT_COMPONENT_TEMPLATE = ( Path(__file__).parent / "pyscript" / "component_template.py" ).read_text(encoding="utf-8") -PYSCRIPT_TAG = make_vdom_constructor("py-script") PYSCRIPT_DEFAULT_CONFIG = { "packages": [ f"reactpy=={reactpy.__version__}", From 9bd02bf8876a4a1226bcfe44db80437b1eaee918 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 17:48:00 -0700 Subject: [PATCH 05/28] add more fixme details --- src/reactpy_django/components.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 188b656e..75df424e 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -328,9 +328,12 @@ def _python_to_pyscript( new_config = extend_pyscript_config(config, extra_packages) if not rendered: - # FIXME: This is needed to properly kill off any previous PyScript instances - # such as when a component is re-rendered due to WebSocket disconnection. - # There may be a better way to do this, but it's not clear at the moment + # FIXME: This is needed to properly re-render PyScript instances such as + # when a component is re-rendered due to WebSocket disconnection. + # There may be a better way to do this in the future. + # While this solution allows re-creating PyScript components, it also + # results in a browser memory leak. It currently unclear how to properly + # clean up unused code (def user_workspace_UUID) from PyScript. set_rendered(True) return None From 300d14100e429f7816e4a0bb2d9dc281f7146837 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:36:26 -0700 Subject: [PATCH 06/28] reusable layout manager --- src/reactpy_django/components.py | 14 +--- src/reactpy_django/config.py | 5 +- .../pyscript/component_template.py | 78 +------------------ src/reactpy_django/pyscript/layout_manager.py | 77 ++++++++++++++++++ .../static/reactpy_django/pyscript-custom.css | 3 + .../reactpy_django/pyscript-hide-debug.css | 3 + .../templates/reactpy/pyscript_component.html | 6 +- ..._static_files.html => pyscript_setup.html} | 4 + src/reactpy_django/templatetags/reactpy.py | 26 ++++--- src/reactpy_django/utils.py | 7 +- tests/test_app/templates/pyscript.html | 2 +- 11 files changed, 120 insertions(+), 105 deletions(-) create mode 100644 src/reactpy_django/pyscript/layout_manager.py create mode 100644 src/reactpy_django/static/reactpy_django/pyscript-custom.css create mode 100644 src/reactpy_django/static/reactpy_django/pyscript-hide-debug.css rename src/reactpy_django/templates/reactpy/{pyscript_static_files.html => pyscript_setup.html} (55%) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 75df424e..4b9f2409 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -158,18 +158,14 @@ def django_js(static_path: str, key: Key | None = None): def python_to_pyscript( file_path: str, - *extra_packages: str, extra_props: dict[str, Any] | None = None, initial: str | VdomDict | ComponentType = "", - config: str | dict = "", root: str = "root", ): return _python_to_pyscript( file_path, - *extra_packages, extra_props=extra_props, initial=initial, - config=config, root=root, ) @@ -315,17 +311,14 @@ def _cached_static_contents(static_path: str) -> str: @component def _python_to_pyscript( file_path: str, - *extra_packages: str, extra_props: dict[str, Any] | None = None, initial: str | VdomDict | ComponentType = "", - config: str | dict = "", root: str = "root", ): rendered, set_rendered = hooks.use_state(False) uuid = uuid4().hex.replace("-", "") initial = vdom_or_component_to_string(initial, uuid=uuid) executor = render_pyscript_template(file_path, uuid, root) - new_config = extend_pyscript_config(config, extra_packages) if not rendered: # FIXME: This is needed to properly re-render PyScript instances such as @@ -333,14 +326,11 @@ def _python_to_pyscript( # There may be a better way to do this in the future. # While this solution allows re-creating PyScript components, it also # results in a browser memory leak. It currently unclear how to properly - # clean up unused code (def user_workspace_UUID) from PyScript. + # clean up unused code (user_workspace_UUID) from PyScript. set_rendered(True) return None return html.div( html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial), - pyscript( - {"async": "", "config": orjson.dumps(new_config).decode()}, - executor, - ), + pyscript({"async": ""}, executor), ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index cabb61a4..21a30a32 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -7,7 +7,7 @@ from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS from django.views import View -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG_MODE as _REACTPY_DEBUG_MODE from reactpy.core.types import ComponentConstructor from reactpy_django.types import ( @@ -17,7 +17,8 @@ from reactpy_django.utils import import_dotted_path # Non-configurable values -REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) +_REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) +REACTPY_DEBUG_MODE = _REACTPY_DEBUG_MODE.current REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() REACTPY_REGISTERED_IFRAME_VIEWS: dict[str, Callable | View] = {} diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index 2ab3fc90..a69b2f00 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -3,12 +3,10 @@ Our template tag performs string substitutions to turn this file into valid PyScript.""" -import asyncio +from typing import TYPE_CHECKING -import js -from jsonpointer import set_pointer -from pyodide.ffi.wrappers import add_event_listener -from reactpy.core.layout import Layout +if TYPE_CHECKING: + from reactpy_django.pyscript.layout_manager import ReactPyLayoutManager # User component is inserted below by regex replacement @@ -21,74 +19,6 @@ def root(): ... return root() -# ReactPy layout rendering starts here -class LayoutManagerUUID: - """Encapsulate an entire layout manager with a completely unique class to prevent - rendering bugs caused by the PyScript global interpreter.""" - - @staticmethod - def apply_update(update, root_model): - if update["path"]: - set_pointer(root_model, update["path"], update["model"]) - else: - root_model.update(update["model"]) - - def render(self, layout, model): - container = js.document.getElementById("pyscript-UUID") - container.innerHTML = "" - self.build_element_tree(layout, container, model) - - def build_element_tree(self, layout, parent, model): - if isinstance(model, str): - parent.appendChild(js.document.createTextNode(model)) - elif isinstance(model, dict): - if not model["tagName"]: - for child in model.get("children", []): - self.build_element_tree(layout, parent, child) - return - tag = model["tagName"] - attributes = model.get("attributes", {}) - children = model.get("children", []) - element = js.document.createElement(tag) - for key, value in attributes.items(): - if key == "style": - for style_key, style_value in value.items(): - setattr(element.style, style_key, style_value) - else: - element.setAttribute(key, value) - for event_name, event_handler_model in model.get( - "eventHandlers", {} - ).items(): - self.create_event_handler( - layout, element, event_name, event_handler_model - ) - for child in children: - self.build_element_tree(layout, element, child) - parent.appendChild(element) - else: - raise ValueError(f"Unknown model type: {type(model)}") - - @staticmethod - def create_event_handler(layout, element, event_name, event_handler_model): - target = event_handler_model["target"] - - def event_handler(*args): - asyncio.create_task( - layout.deliver({"type": "layout-event", "target": target, "data": args}) - ) - - event_name = event_name.lstrip("on_").lower().replace("_", "") - add_event_listener(element, event_name, event_handler) - - async def run(self): - root_model = {} - async with Layout(user_workspace_UUID()) as layout: - while True: - update = await layout.render() - self.apply_update(update, root_model) - self.render(layout, root_model) - - # PyScript allows top-level await, which allows us to not throw errors on components # that terminate early (such as hook-less components) -await LayoutManagerUUID().run() # noqa: F704 +await ReactPyLayoutManager("UUID").run(user_workspace_UUID) # noqa: F704 diff --git a/src/reactpy_django/pyscript/layout_manager.py b/src/reactpy_django/pyscript/layout_manager.py new file mode 100644 index 00000000..b720bd71 --- /dev/null +++ b/src/reactpy_django/pyscript/layout_manager.py @@ -0,0 +1,77 @@ +import asyncio +from typing import Coroutine + +import js +from jsonpointer import set_pointer +from pyodide.ffi.wrappers import add_event_listener +from reactpy.core.layout import Layout + + +class ReactPyLayoutManager: + """Encapsulate the entire layout manager with a class to prevent overlapping + variable names between user code.""" + + def __init__(self, uuid): + self.uuid = uuid + + @staticmethod + def apply_update(update, root_model): + if update["path"]: + set_pointer(root_model, update["path"], update["model"]) + else: + root_model.update(update["model"]) + + def render(self, layout, model): + container = js.document.getElementById(f"pyscript-{self.uuid}") + container.innerHTML = "" + self.build_element_tree(layout, container, model) + + def build_element_tree(self, layout, parent, model): + if isinstance(model, str): + parent.appendChild(js.document.createTextNode(model)) + elif isinstance(model, dict): + if not model["tagName"]: + for child in model.get("children", []): + self.build_element_tree(layout, parent, child) + return + tag = model["tagName"] + attributes = model.get("attributes", {}) + children = model.get("children", []) + element = js.document.createElement(tag) + for key, value in attributes.items(): + if key == "style": + for style_key, style_value in value.items(): + setattr(element.style, style_key, style_value) + else: + element.setAttribute(key, value) + for event_name, event_handler_model in model.get( + "eventHandlers", {} + ).items(): + self.create_event_handler( + layout, element, event_name, event_handler_model + ) + for child in children: + self.build_element_tree(layout, element, child) + parent.appendChild(element) + else: + raise ValueError(f"Unknown model type: {type(model)}") + + @staticmethod + def create_event_handler(layout, element, event_name, event_handler_model): + target = event_handler_model["target"] + + def event_handler(*args): + asyncio.create_task( + layout.deliver({"type": "layout-event", "target": target, "data": args}) + ) + + event_name = event_name.lstrip("on_").lower().replace("_", "") + add_event_listener(element, event_name, event_handler) + + async def run(self, workspace_function: Coroutine): + root_model = {} + async with Layout(workspace_function()) as layout: + while True: + update = await layout.render() + self.apply_update(update, root_model) + self.render(layout, root_model) diff --git a/src/reactpy_django/static/reactpy_django/pyscript-custom.css b/src/reactpy_django/static/reactpy_django/pyscript-custom.css new file mode 100644 index 00000000..5793fd52 --- /dev/null +++ b/src/reactpy_django/static/reactpy_django/pyscript-custom.css @@ -0,0 +1,3 @@ +py-script { + display: none; +} diff --git a/src/reactpy_django/static/reactpy_django/pyscript-hide-debug.css b/src/reactpy_django/static/reactpy_django/pyscript-hide-debug.css new file mode 100644 index 00000000..9cd8541e --- /dev/null +++ b/src/reactpy_django/static/reactpy_django/pyscript-hide-debug.css @@ -0,0 +1,3 @@ +.py-error { + display: none; +} diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html index ed8eef98..cc56e8da 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_component.html +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -1,4 +1,4 @@ -{% if reactpy_class %}
{{reactpy_initial_html}}
+{% if reactpy_class %}
{{pyscript_initial_html}}
{% endif %} -{% if not reactpy_class %}
{{reactpy_initial_html}}
{% endif %} -{{ reactpy_executor }} +{% if not reactpy_class %}
{{pyscript_initial_html}}
{% endif %} +{{pyscript_executor}} diff --git a/src/reactpy_django/templates/reactpy/pyscript_static_files.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html similarity index 55% rename from src/reactpy_django/templates/reactpy/pyscript_static_files.html rename to src/reactpy_django/templates/reactpy/pyscript_setup.html index 9051fd65..a96c8704 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_static_files.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -1,4 +1,8 @@ {% load static %} +{% if not reactpy_debug_mode %} + +{% endif %} +{{pyscript_layout_manager}} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 02cbb419..a8e1f64b 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -4,7 +4,6 @@ from uuid import uuid4 import dill as pickle -import orjson from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse @@ -21,6 +20,7 @@ ) from reactpy_django.types import ComponentParams from reactpy_django.utils import ( + PYSCRIPT_LAYOUT_MANAGER, extend_pyscript_config, prerender_component, render_pyscript_template, @@ -211,25 +211,29 @@ def validate_host(host: str): def pyscript_component( context: template.RequestContext, file_path: str, - *extra_packages: str, initial: str | VdomDict | ComponentType = "", - config: str | dict = "", root: str = "root", ): uuid = uuid4().hex request: HttpRequest | None = context.get("request") initial = vdom_or_component_to_string(initial, request=request, uuid=uuid) executor = render_pyscript_template(file_path, uuid, root) - new_config = extend_pyscript_config(config, extra_packages) return { - "reactpy_executor": executor, - "reactpy_uuid": uuid, - "reactpy_initial_html": initial, - "reactpy_config": orjson.dumps(new_config).decode(), + "pyscript_executor": executor, + "pyscript_uuid": uuid, + "pyscript_initial_html": initial, } -@register.inclusion_tag("reactpy/pyscript_static_files.html") -def pyscript_static_files(): - return {} +@register.inclusion_tag("reactpy/pyscript_setup.html") +def pyscript_setup( + *extra_packages: str, + config: str | dict = "", +): + print(reactpy_config.REACTPY_DEBUG_MODE) + return { + "pyscript_config": extend_pyscript_config(config, extra_packages), + "pyscript_layout_manager": PYSCRIPT_LAYOUT_MANAGER, + "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 3c38019a..172f1d82 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -57,6 +57,9 @@ PYSCRIPT_COMPONENT_TEMPLATE = ( Path(__file__).parent / "pyscript" / "component_template.py" ).read_text(encoding="utf-8") +PYSCRIPT_LAYOUT_MANAGER = ( + Path(__file__).parent / "pyscript" / "layout_manager.py" +).read_text(encoding="utf-8") PYSCRIPT_DEFAULT_CONFIG = { "packages": [ f"reactpy=={reactpy.__version__}", @@ -468,7 +471,7 @@ def render_pyscript_template(file_path: str, uuid: str, root: str): return executor -def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> dict: +def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: """Extends the default PyScript configuration with user configuration.""" pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) pyscript_config["packages"].extend(extra_packages) @@ -476,4 +479,4 @@ def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> dict pyscript_config.update(orjson.loads(config)) elif isinstance(config, dict): pyscript_config.update(config) - return pyscript_config + return orjson.dumps(pyscript_config).decode("utf-8") diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 81ee1fe7..3f247597 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -8,7 +8,7 @@ ReactPy - {% pyscript_static_files %} + {% pyscript_setup %} From 0210f79acd719abb4378ef91ccf474ca04ef866d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 19:46:17 -0700 Subject: [PATCH 07/28] fix client memory leak --- src/reactpy_django/components.py | 11 +++--- .../pyscript/component_template.py | 18 ++++++---- src/reactpy_django/pyscript/layout_manager.py | 36 ++++++++++++++++++- .../templates/reactpy/pyscript_component.html | 4 +-- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 4b9f2409..c9599afc 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -7,7 +7,6 @@ from uuid import uuid4 from warnings import warn -import orjson from django.contrib.staticfiles.finders import find from django.core.cache import caches from django.http import HttpRequest @@ -19,7 +18,6 @@ from reactpy_django.exceptions import ViewNotRegisteredError from reactpy_django.html import pyscript from reactpy_django.utils import ( - extend_pyscript_config, generate_obj_name, import_module, render_pyscript_template, @@ -324,13 +322,14 @@ def _python_to_pyscript( # FIXME: This is needed to properly re-render PyScript instances such as # when a component is re-rendered due to WebSocket disconnection. # There may be a better way to do this in the future. - # While this solution allows re-creating PyScript components, it also - # results in a browser memory leak. It currently unclear how to properly - # clean up unused code (user_workspace_UUID) from PyScript. set_rendered(True) return None return html.div( - html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial), + html.div( + (extra_props or {}) + | {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, + initial, + ), pyscript({"async": ""}, executor), ) diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index a69b2f00..040433a5 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -1,18 +1,22 @@ -"""Code within this module is designed to be run directly by PyScript, and is not -intended to be run in a Django environment. - -Our template tag performs string substitutions to turn this file into valid PyScript.""" - +# pylint: disable=used-before-assignment from typing import TYPE_CHECKING if TYPE_CHECKING: + import asyncio + from reactpy_django.pyscript.layout_manager import ReactPyLayoutManager # User component is inserted below by regex replacement def user_workspace_UUID(): """Encapsulate the user's code with a completely unique function (workspace) - to prevent overlapping imports and variable names between different components.""" + to prevent overlapping imports and variable names between different components. + + This code is designed to be run directly by PyScript, and is not intended to be run + in a standard Python environment. + + Our template tag performs string substitutions to turn this file into valid PyScript. + """ def root(): ... @@ -21,4 +25,4 @@ def root(): ... # PyScript allows top-level await, which allows us to not throw errors on components # that terminate early (such as hook-less components) -await ReactPyLayoutManager("UUID").run(user_workspace_UUID) # noqa: F704 +task_UUID = asyncio.create_task(ReactPyLayoutManager("UUID").run(user_workspace_UUID)) diff --git a/src/reactpy_django/pyscript/layout_manager.py b/src/reactpy_django/pyscript/layout_manager.py index b720bd71..5e2b20be 100644 --- a/src/reactpy_django/pyscript/layout_manager.py +++ b/src/reactpy_django/pyscript/layout_manager.py @@ -9,7 +9,11 @@ class ReactPyLayoutManager: """Encapsulate the entire layout manager with a class to prevent overlapping - variable names between user code.""" + variable names between user code. + + This code is designed to be run directly by PyScript, and is not intended to be run + in a standard Python environment. + """ def __init__(self, uuid): self.uuid = uuid @@ -68,8 +72,38 @@ def event_handler(*args): event_name = event_name.lstrip("on_").lower().replace("_", "") add_event_listener(element, event_name, event_handler) + @staticmethod + def delete_old_workspaces(): + dom_workspaces = js.document.querySelectorAll(".pyscript") + dom_uuids = {element.dataset.uuid for element in dom_workspaces} + python_uuids = { + value.split("_")[-1] + for value in globals() + if value.startswith("user_workspace_") + } + + # Delete the workspace if it exists at the moment when we check + for uuid in python_uuids - dom_uuids: + task_name = f"task_{uuid}" + if task_name in globals(): + task: asyncio.Task = globals()[task_name] + task.cancel() + del globals()[task_name] + else: + print(f"Warning: Could not auto delete PyScript task {task_name}") + + workspace_name = f"user_workspace_{uuid}" + if workspace_name in globals(): + del globals()[workspace_name] + else: + print( + f"Warning: Could not auto delete PyScript workspace {workspace_name}" + ) + async def run(self, workspace_function: Coroutine): + self.delete_old_workspaces() root_model = {} + async with Layout(workspace_function()) as layout: while True: update = await layout.render() diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html index cc56e8da..a4767040 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_component.html +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -1,4 +1,2 @@ -{% if reactpy_class %}
{{pyscript_initial_html}}
-{% endif %} -{% if not reactpy_class %}
{{pyscript_initial_html}}
{% endif %} +
{{pyscript_initial_html}}
{{pyscript_executor}} From 62579bd376c679dab885928128c1ae1b2df3f6bc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 19:56:08 -0700 Subject: [PATCH 08/28] layout manager -> layout handler --- src/reactpy_django/pyscript/component_template.py | 4 ++-- .../pyscript/{layout_manager.py => layout_handler.py} | 4 ++-- src/reactpy_django/templates/reactpy/pyscript_setup.html | 2 +- src/reactpy_django/templatetags/reactpy.py | 4 ++-- src/reactpy_django/utils.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/reactpy_django/pyscript/{layout_manager.py => layout_handler.py} (97%) diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index 040433a5..d3eaaa5d 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: import asyncio - from reactpy_django.pyscript.layout_manager import ReactPyLayoutManager + from reactpy_django.pyscript.layout_handler import ReactPyLayoutHandler # User component is inserted below by regex replacement @@ -25,4 +25,4 @@ def root(): ... # PyScript allows top-level await, which allows us to not throw errors on components # that terminate early (such as hook-less components) -task_UUID = asyncio.create_task(ReactPyLayoutManager("UUID").run(user_workspace_UUID)) +task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID)) diff --git a/src/reactpy_django/pyscript/layout_manager.py b/src/reactpy_django/pyscript/layout_handler.py similarity index 97% rename from src/reactpy_django/pyscript/layout_manager.py rename to src/reactpy_django/pyscript/layout_handler.py index 5e2b20be..bbd5dc57 100644 --- a/src/reactpy_django/pyscript/layout_manager.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -7,8 +7,8 @@ from reactpy.core.layout import Layout -class ReactPyLayoutManager: - """Encapsulate the entire layout manager with a class to prevent overlapping +class ReactPyLayoutHandler: + """Encapsulate the entire layout handler with a class to prevent overlapping variable names between user code. This code is designed to be run directly by PyScript, and is not intended to be run diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index a96c8704..0b07fae3 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -5,4 +5,4 @@ {% endif %} -{{pyscript_layout_manager}} +{{pyscript_layout_handler}} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index a8e1f64b..32241fba 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -20,7 +20,7 @@ ) from reactpy_django.types import ComponentParams from reactpy_django.utils import ( - PYSCRIPT_LAYOUT_MANAGER, + PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, prerender_component, render_pyscript_template, @@ -234,6 +234,6 @@ def pyscript_setup( print(reactpy_config.REACTPY_DEBUG_MODE) return { "pyscript_config": extend_pyscript_config(config, extra_packages), - "pyscript_layout_manager": PYSCRIPT_LAYOUT_MANAGER, + "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 172f1d82..da06cd6f 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -57,8 +57,8 @@ PYSCRIPT_COMPONENT_TEMPLATE = ( Path(__file__).parent / "pyscript" / "component_template.py" ).read_text(encoding="utf-8") -PYSCRIPT_LAYOUT_MANAGER = ( - Path(__file__).parent / "pyscript" / "layout_manager.py" +PYSCRIPT_LAYOUT_HANDLER = ( + Path(__file__).parent / "pyscript" / "layout_handler.py" ).read_text(encoding="utf-8") PYSCRIPT_DEFAULT_CONFIG = { "packages": [ From d4e6abecce0f4f9a0e7a97c1b75452c10c0f73bd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 21:34:45 -0700 Subject: [PATCH 09/28] fix type hints --- src/reactpy_django/pyscript/layout_handler.py | 8 +++++--- src/reactpy_django/utils.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index bbd5dc57..8478ac73 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -1,10 +1,12 @@ +# mypy: disable-error-code=attr-defined import asyncio -from typing import Coroutine +from typing import Callable import js from jsonpointer import set_pointer from pyodide.ffi.wrappers import add_event_listener from reactpy.core.layout import Layout +from reactpy.types import ComponentType class ReactPyLayoutHandler: @@ -100,9 +102,9 @@ def delete_old_workspaces(): f"Warning: Could not auto delete PyScript workspace {workspace_name}" ) - async def run(self, workspace_function: Coroutine): + async def run(self, workspace_function: Callable[[], ComponentType]): self.delete_old_workspaces() - root_model = {} + root_model: dict = {} async with Layout(workspace_function()) as layout: while True: diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index da06cd6f..0b85b3c5 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -11,7 +11,7 @@ from fnmatch import fnmatch from importlib import import_module from pathlib import Path -from typing import Any, Callable, Sequence +from typing import Any, Callable, Mapping, Sequence from uuid import UUID, uuid4 import jsonpointer @@ -407,7 +407,11 @@ def strtobool(val): def prerender_component( - user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest + user_component: ComponentConstructor, + args: Sequence, + kwargs: Mapping, + uuid: str | UUID, + request: HttpRequest, ) -> str: """Prerenders a ReactPy component and returns the HTML string.""" search = request.GET.urlencode() @@ -432,12 +436,12 @@ def prerender_component( def vdom_or_component_to_string( - vdom_or_component: Any, request: HttpRequest | None = None, uuid: UUID | None = None + vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None ) -> str: """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be automatically returned.""" if isinstance(vdom_or_component, dict): - return vdom_to_html(vdom_or_component) + return vdom_to_html(vdom_or_component) # type: ignore if hasattr(vdom_or_component, "render"): if not request: From be6a8d5790dde99302dcbf7b1c24ef5ecaf9535f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 22:02:42 -0700 Subject: [PATCH 10/28] workflow for pyscript dist --- .gitignore | 3 +- setup.py | 16 ++++- src/js/package-lock.json | 134 ++++++++++++++++++++++++++++++++++++- src/js/package.json | 3 +- tests/test_app/__init__.py | 16 +++++ 5 files changed, 165 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 07d4c0cd..e8b35e23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ReactPy-Django Build Artifacts -src/reactpy_django/static/* +src/reactpy_django/static/reactpy_django/client.js +src/reactpy_django/static/reactpy_django/pyscript # Django # logs diff --git a/setup.py b/setup.py index b99d550b..056788f6 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ from __future__ import annotations, print_function +import shutil import sys import traceback from distutils import log @@ -16,7 +17,9 @@ name = "reactpy_django" root_dir = Path(__file__).parent src_dir = root_dir / "src" +js_dir = src_dir / "js" package_dir = src_dir / name +static_dir = package_dir / "static" / name # ----------------------------------------------------------------------------- @@ -97,22 +100,29 @@ def build_javascript_first(build_cls: type): class Command(build_cls): def run(self): - js_dir = str(src_dir / "js") log.info("Installing Javascript...") - result = npm.call(["install"], cwd=js_dir) + result = npm.call(["install"], cwd=str(js_dir)) if result != 0: log.error(traceback.format_exc()) log.error("Failed to install Javascript") raise RuntimeError("Failed to install Javascript") log.info("Building Javascript...") - result = npm.call(["run", "build"], cwd=js_dir) + result = npm.call(["run", "build"], cwd=str(js_dir)) if result != 0: log.error(traceback.format_exc()) log.error("Failed to build Javascript") raise RuntimeError("Failed to build Javascript") + log.info("Copying @pyscript/core distribution") + pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" + pyscript_static_dir = static_dir / "pyscript" + if not pyscript_static_dir.exists(): + pyscript_static_dir.mkdir() + for file in pyscript_dist.iterdir(): + shutil.copy(file, pyscript_static_dir / file.name) + log.info("Successfully built Javascript") super().run() diff --git a/src/js/package-lock.json b/src/js/package-lock.json index b0390203..c93c0882 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@pyscript/core": "^0.4.48", "@reactpy/client": "^0.3.1", "@rollup/plugin-typescript": "^11.1.6", "tslib": "^2.6.2" @@ -205,6 +206,19 @@ "node": ">= 8" } }, + "node_modules/@pyscript/core": { + "version": "0.4.48", + "resolved": "https://registry.npmjs.org/@pyscript/core/-/core-0.4.48.tgz", + "integrity": "sha512-cVZ//1WDkWhjZ1tOjUB1YJ5mKxDf3kMpzS/pw7Oe9/BMrB/NM3TxxCQ9Oyvq7Fkfv1F+srIcsi1xZ5gQeP+5Tg==", + "dependencies": { + "@ungap/with-resolvers": "^0.1.0", + "basic-devtools": "^0.1.6", + "polyscript": "^0.13.5", + "sticky-module": "^0.1.1", + "to-json-callback": "^0.1.1", + "type-checked-collections": "^0.1.7" + } + }, "node_modules/@reactpy/client": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", @@ -550,8 +564,22 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + }, + "node_modules/@ungap/with-resolvers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ungap/with-resolvers/-/with-resolvers-0.1.0.tgz", + "integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==" + }, + "node_modules/@webreflection/fetch": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", + "integrity": "sha512-zCcqCJoNLvdeF41asAK71XPlwSPieeRDsE09albBunJEksuYPYNillKNQjf8p5BqSoTKTuKrW3lUm3MNodUC4g==" + }, + "node_modules/@webreflection/idb-map": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@webreflection/idb-map/-/idb-map-0.3.1.tgz", + "integrity": "sha512-lRCanqwR7tHHFohJHAMSMEZnoNPvgjcKr0f5e4y+lTJA+fctT61EZ+f5pT5/+8+wlSsMAvXjzfKRLT6o9aqxbA==" }, "node_modules/acorn": { "version": "8.11.3", @@ -749,6 +777,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/basic-devtools": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/basic-devtools/-/basic-devtools-0.1.6.tgz", + "integrity": "sha512-g9zJ63GmdUesS3/Fwv0B5SYX6nR56TQXmGr+wE5PRTNCnGQMYWhUx/nZB/mMWnQJVLPPAp89oxDNlasdtNkW5Q==" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -809,6 +842,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/codedent": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/codedent/-/codedent-0.1.2.tgz", + "integrity": "sha512-qEqzcy5viM3UoCN0jYHZeXZoyd4NZQzYFg0kOBj8O1CgoGG9WYYTF+VeQRsN0OSKFjF3G1u4WDUOtOsWEx6N2w==", + "dependencies": { + "plain-tag": "^0.1.3" + } + }, + "node_modules/coincident": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz", + "integrity": "sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==", + "dependencies": { + "@ungap/structured-clone": "^1.2.0", + "@ungap/with-resolvers": "^0.1.0", + "gc-hook": "^0.3.1", + "proxy-target": "^3.0.2" + }, + "optionalDependencies": { + "ws": "^8.16.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1463,6 +1518,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gc-hook": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", + "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" + }, "node_modules/get-intrinsic": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", @@ -1653,6 +1713,11 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, "node_modules/ignore": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -2440,6 +2505,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plain-tag": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/plain-tag/-/plain-tag-0.1.3.tgz", + "integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA==" + }, + "node_modules/polyscript": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.13.5.tgz", + "integrity": "sha512-PwXWnhLbOMtvZWFIN271JhaN7KnxESaMtv9Rcdrq1TKTCMnkz9idvYb3Od1iumBJlr49lLlwyUKeGb423rFR4w==", + "dependencies": { + "@ungap/structured-clone": "^1.2.0", + "@ungap/with-resolvers": "^0.1.0", + "@webreflection/fetch": "^0.1.5", + "@webreflection/idb-map": "^0.3.1", + "basic-devtools": "^0.1.6", + "codedent": "^0.1.2", + "coincident": "^1.2.3", + "gc-hook": "^0.3.1", + "html-escaper": "^3.0.3", + "proxy-target": "^3.0.2", + "sticky-module": "^0.1.1", + "to-json-callback": "^0.1.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2475,6 +2564,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-target": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/proxy-target/-/proxy-target-3.0.2.tgz", + "integrity": "sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2840,6 +2934,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sticky-module": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sticky-module/-/sticky-module-0.1.1.tgz", + "integrity": "sha512-IuYgnyIMUx/m6rtu14l/LR2MaqOLtpXcWkxPmtPsiScRHEo+S4Tojk+DWFHOncSdFX/OsoLOM4+T92yOmI1AMw==" + }, "node_modules/string.prototype.matchall": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", @@ -2958,6 +3057,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/to-json-callback": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/to-json-callback/-/to-json-callback-0.1.1.tgz", + "integrity": "sha512-BzOeinTT3NjE+FJ2iCvWB8HvyuyBzoH3WlSnJ+AYVC4tlePyZWSYdkQIFOARWiq0t35/XhmI0uQsFiUsRksRqg==" + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -2975,6 +3079,11 @@ "node": ">= 0.8.0" } }, + "node_modules/type-checked-collections": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/type-checked-collections/-/type-checked-collections-0.1.7.tgz", + "integrity": "sha512-fLIydlJy7IG9XL4wjRwEcKhxx/ekLXiWiMvcGo01cOMF+TN+5ZqajM1mRNRz2bNNi1bzou2yofhjZEQi7kgl9A==" + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3185,6 +3294,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/js/package.json b/src/js/package.json index 8d2d9ff5..f23efc5d 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -13,13 +13,14 @@ "@rollup/plugin-replace": "^5.0.5", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", - "prettier": "^3.2.3", "eslint": "^8.38.0", "eslint-plugin-react": "^7.32.2", + "prettier": "^3.2.3", "rollup": "^4.9.5", "typescript": "^5.3.3" }, "dependencies": { + "@pyscript/core": "^0.4.48", "@reactpy/client": "^0.3.1", "@rollup/plugin-typescript": "^11.1.6", "tslib": "^2.6.2" diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 86dbd107..8539f5fd 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path from nodejs import npm @@ -6,3 +7,18 @@ js_dir = Path(__file__).parent.parent.parent / "src" / "js" assert npm.call(["install"], cwd=str(js_dir)) == 0 assert npm.call(["run", "build"], cwd=str(js_dir)) == 0 + +# Make sure the current PyScript distribution is available +pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" +pyscript_static_dir = ( + Path(__file__).parent.parent.parent + / "src" + / "reactpy_django" + / "static" + / "reactpy_django" + / "pyscript" +) +if not pyscript_static_dir.exists(): + pyscript_static_dir.mkdir() +for file in pyscript_dist.iterdir(): + shutil.copy(file, pyscript_static_dir / file.name) From c90fbff8879a2fbd4017b251450b033ccd66fef8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jun 2024 22:20:50 -0700 Subject: [PATCH 11/28] quick PR self review --- src/reactpy_django/components.py | 4 ++-- src/reactpy_django/management/commands/clean_reactpy.py | 4 ++-- src/reactpy_django/templatetags/reactpy.py | 1 - src/reactpy_django/utils.py | 4 ++-- tests/test_app/__init__.py | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index c9599afc..6c08b1ae 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -319,8 +319,8 @@ def _python_to_pyscript( executor = render_pyscript_template(file_path, uuid, root) if not rendered: - # FIXME: This is needed to properly re-render PyScript instances such as - # when a component is re-rendered due to WebSocket disconnection. + # FIXME: This is needed to properly re-render PyScript such as + # during a WebSocket disconnection / reconnection. # There may be a better way to do this in the future. set_rendered(True) return None diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py index bfde6f2f..0c5dc308 100644 --- a/src/reactpy_django/management/commands/clean_reactpy.py +++ b/src/reactpy_django/management/commands/clean_reactpy.py @@ -28,10 +28,10 @@ def add_arguments(self, parser): parser.add_argument( "--sessions", action="store_true", - help="Configure this clean to only clean session data (and other configured cleaning options).", + help="Clean session data. This value can be combined with other cleaning options.", ) parser.add_argument( "--user-data", action="store_true", - help="Configure this clean to only clean user data (and other configured cleaning options).", + help="Clean user data. This value can be combined with other cleaning options.", ) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 32241fba..ad6e7b7c 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -231,7 +231,6 @@ def pyscript_setup( *extra_packages: str, config: str | dict = "", ): - print(reactpy_config.REACTPY_DEBUG_MODE) return { "pyscript_config": extend_pyscript_config(config, extra_packages), "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 0b85b3c5..3b0876a5 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -461,7 +461,7 @@ def vdom_or_component_to_string( def render_pyscript_template(file_path: str, uuid: str, root: str): - """Inserts the user's code into our PyScript template using pattern matching.""" + """Inserts the user's code into the PyScript template using pattern matching.""" # Create a valid PyScript executor by replacing the template values executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) executor = executor.replace("return root()", f"return {root}()") @@ -476,7 +476,7 @@ def render_pyscript_template(file_path: str, uuid: str, root: str): def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: - """Extends the default PyScript configuration with user configuration.""" + """Extends ReactPy's default PyScript config with user provided values.""" pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) pyscript_config["packages"].extend(extra_packages) if config and isinstance(config, str): diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 8539f5fd..7c4a70c6 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -8,7 +8,7 @@ assert npm.call(["install"], cwd=str(js_dir)) == 0 assert npm.call(["run", "build"], cwd=str(js_dir)) == 0 -# Make sure the current PyScript distribution is available +# Make sure the the PyScript distribution is always available pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" pyscript_static_dir = ( Path(__file__).parent.parent.parent From 59c351ab2e23b31a12f629db0a945ce27a39c1c4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:05:11 -0700 Subject: [PATCH 12/28] add tests --- src/reactpy_django/pyscript/layout_handler.py | 2 + tests/test_app/pyscript/components/child.py | 42 ++++++++----------- .../pyscript/components/child_embed.py | 16 ------- tests/test_app/pyscript/components/counter.py | 15 +++++-- .../pyscript/components/hello_world.py | 2 +- .../pyscript/components/server_side.py | 33 +++++++++++++++ tests/test_app/templates/pyscript.html | 4 +- tests/test_app/tests/test_components.py | 39 +++++++++++++++++ 8 files changed, 106 insertions(+), 47 deletions(-) delete mode 100644 tests/test_app/pyscript/components/child_embed.py create mode 100644 tests/test_app/pyscript/components/server_side.py diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 8478ac73..23acc4c5 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -48,6 +48,8 @@ def build_element_tree(self, layout, parent, model): if key == "style": for style_key, style_value in value.items(): setattr(element.style, style_key, style_value) + elif key == "className": + element.className = value else: element.setAttribute(key, value) for event_name, event_handler_model in model.get( diff --git a/tests/test_app/pyscript/components/child.py b/tests/test_app/pyscript/components/child.py index 7daed4cb..1f4a7824 100644 --- a/tests/test_app/pyscript/components/child.py +++ b/tests/test_app/pyscript/components/child.py @@ -1,33 +1,25 @@ from reactpy import component, html, use_state -from reactpy_django.components import python_to_pyscript @component -def embed(): - return html.div( - {"className": "embeddable"}, - python_to_pyscript("./test_app/pyscript/components/child_embed.py"), - ) - - -@component -def toggeable_embed(): - state, set_state = use_state(False) - - if not state: - return html.div( - {"className": "embeddable"}, +def root(): + value, set_value = use_state(0) + return html.article( + {"id": "child"}, + "This was embedded via a server-side component.", + html.div( + {"className": "grid"}, html.button( - {"onClick": lambda x: set_state(not state)}, - "Click to show/hide", + {"className": "plus", "on_click": lambda event: set_value(value + 1)}, + "+", ), - ) - - return html.div( - {"className": "embeddable"}, - html.button( - {"onClick": lambda x: set_state(not state)}, - "Click to show/hide", + html.button( + {"className": "minus", "on_click": lambda event: set_value(value - 1)}, + "-", + ), + ), + "Current value", + html.pre( + {"style": {"font-style": "bold"}, "data-value": str(value)}, str(value) ), - python_to_pyscript("./test_app/pyscript/components/child_embed.py"), ) diff --git a/tests/test_app/pyscript/components/child_embed.py b/tests/test_app/pyscript/components/child_embed.py deleted file mode 100644 index 3b2a0fbb..00000000 --- a/tests/test_app/pyscript/components/child_embed.py +++ /dev/null @@ -1,16 +0,0 @@ -from reactpy import component, html, use_state - - -@component -def root(): - value, set_value = use_state(0) - return html.article( - "This was embedded via a server-side component.", - html.div( - {"className": "grid"}, - html.button({"on_click": lambda event: set_value(value + 1)}, "+"), - html.button({"on_click": lambda event: set_value(value - 1)}, "-"), - ), - "Current value", - html.pre({"style": {"font-style": "bold"}}, str(value)), - ) diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py index 8d024e17..31df55a1 100644 --- a/tests/test_app/pyscript/components/counter.py +++ b/tests/test_app/pyscript/components/counter.py @@ -5,11 +5,20 @@ def root(): value, set_value = use_state(0) return html.article( + {"id": "counter"}, html.div( {"className": "grid"}, - html.button({"on_click": lambda event: set_value(value + 1)}, "+"), - html.button({"on_click": lambda event: set_value(value - 1)}, "-"), + html.button( + {"className": "plus", "on_click": lambda event: set_value(value + 1)}, + "+", + ), + html.button( + {"className": "minus", "on_click": lambda event: set_value(value - 1)}, + "-", + ), ), "Current value", - html.pre({"style": {"font-style": "bold"}}, str(value)), + html.pre( + {"style": {"font-style": "bold"}, "data-value": str(value)}, str(value) + ), ) diff --git a/tests/test_app/pyscript/components/hello_world.py b/tests/test_app/pyscript/components/hello_world.py index 8081c028..d8c36ee8 100644 --- a/tests/test_app/pyscript/components/hello_world.py +++ b/tests/test_app/pyscript/components/hello_world.py @@ -3,4 +3,4 @@ @component def root(): - return html.div("hello world") + return html.div({"id": "hello-world"}, "hello world") diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py new file mode 100644 index 00000000..b26ef558 --- /dev/null +++ b/tests/test_app/pyscript/components/server_side.py @@ -0,0 +1,33 @@ +from reactpy import component, html, use_state +from reactpy_django.components import python_to_pyscript + + +@component +def parent(): + return html.div( + {"id": "parent"}, + python_to_pyscript("./test_app/pyscript/components/child.py"), + ) + + +@component +def parent_toggle(): + state, set_state = use_state(False) + + if not state: + return html.div( + {"id": "parent-toggle"}, + html.button( + {"onClick": lambda x: set_state(not state)}, + "Click to show/hide", + ), + ) + + return html.div( + {"id": "parent-toggle"}, + html.button( + {"onClick": lambda x: set_state(not state)}, + "Click to show/hide", + ), + python_to_pyscript("./test_app/pyscript/components/child.py"), + ) diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 3f247597..79804c1b 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -18,9 +18,9 @@

ReactPy PyScript Test Page


{% pyscript_component "./test_app/pyscript/components/counter.py" %}
- {% component "test_app.pyscript.components.child.embed" %} + {% component "test_app.pyscript.components.server_side.parent" %}
- {% component "test_app.pyscript.components.child.toggeable_embed" %} + {% component "test_app.pyscript.components.server_side.parent_toggle" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d92867cd..a6ff2faa 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -678,3 +678,42 @@ def test_channel_layer_components(self): finally: new_page.close() + + def test_pyscript_components(self): + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/pyscript/") + new_page.wait_for_selector("#hello-world") + + new_page.wait_for_selector("#counter") + new_page.wait_for_selector("#counter pre[data-value='0']") + new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#counter pre[data-value='1']") + new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#counter pre[data-value='2']") + new_page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#counter pre[data-value='1']") + + new_page.wait_for_selector("#parent") + new_page.wait_for_selector("#child") + new_page.wait_for_selector("#child pre[data-value='0']") + new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#child pre[data-value='1']") + new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#child pre[data-value='2']") + new_page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#child pre[data-value='1']") + + new_page.wait_for_selector("#parent-toggle") + new_page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#parent-toggle") + new_page.wait_for_selector("#parent-toggle pre[data-value='0']") + new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#parent-toggle pre[data-value='1']") + new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#parent-toggle pre[data-value='2']") + new_page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) + new_page.wait_for_selector("#parent-toggle pre[data-value='1']") + + finally: + new_page.close() From 258194b3e40fee526a8c034c800435934962ebee Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:17:07 -0700 Subject: [PATCH 13/28] more tests --- tests/test_app/pyscript/components/custom_root.py | 6 ++++++ tests/test_app/templates/pyscript.html | 4 +++- tests/test_app/tests/test_components.py | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 tests/test_app/pyscript/components/custom_root.py diff --git a/tests/test_app/pyscript/components/custom_root.py b/tests/test_app/pyscript/components/custom_root.py new file mode 100644 index 00000000..ee44fde4 --- /dev/null +++ b/tests/test_app/pyscript/components/custom_root.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def main(): + return html.div({"id": "custom-root"}, "Component with a custom root name.") diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 79804c1b..424ffdb6 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -14,7 +14,9 @@

ReactPy PyScript Test Page


- {% pyscript_component "./test_app/pyscript/components/hello_world.py" %} + {% pyscript_component "./test_app/pyscript/components/hello_world.py" initial="
Loading...
" %} +
+ {% pyscript_component "./test_app/pyscript/components/custom_root.py" root="main" %}
{% pyscript_component "./test_app/pyscript/components/counter.py" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index a6ff2faa..c0af8126 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -683,7 +683,9 @@ def test_pyscript_components(self): new_page = self.browser.new_page() try: new_page.goto(f"{self.live_server_url}/pyscript/") + new_page.wait_for_selector("#hello-world-loading") new_page.wait_for_selector("#hello-world") + new_page.wait_for_selector("#custom-root") new_page.wait_for_selector("#counter") new_page.wait_for_selector("#counter pre[data-value='0']") From 4ac4dedbebe16343252dc5ed26826502498e4b2a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:55:21 -0700 Subject: [PATCH 14/28] remove extra pros --- src/reactpy_django/components.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 6c08b1ae..4018f6f0 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -156,13 +156,11 @@ def django_js(static_path: str, key: Key | None = None): def python_to_pyscript( file_path: str, - extra_props: dict[str, Any] | None = None, initial: str | VdomDict | ComponentType = "", root: str = "root", ): return _python_to_pyscript( file_path, - extra_props=extra_props, initial=initial, root=root, ) @@ -308,15 +306,14 @@ def _cached_static_contents(static_path: str) -> str: @component def _python_to_pyscript( - file_path: str, - extra_props: dict[str, Any] | None = None, + *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", ): rendered, set_rendered = hooks.use_state(False) uuid = uuid4().hex.replace("-", "") initial = vdom_or_component_to_string(initial, uuid=uuid) - executor = render_pyscript_template(file_path, uuid, root) + executor = render_pyscript_template(file_paths, uuid, root) if not rendered: # FIXME: This is needed to properly re-render PyScript such as @@ -325,10 +322,9 @@ def _python_to_pyscript( set_rendered(True) return None - return html.div( + return html._( html.div( - (extra_props or {}) - | {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, + {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, initial, ), pyscript({"async": ""}, executor), From bc58040054af29df77d48c7e0498f84355ea7d49 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:55:54 -0700 Subject: [PATCH 15/28] Cache file reads for pyscript code --- src/reactpy_django/utils.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 3b0876a5..cd479a1d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -460,19 +460,43 @@ def vdom_or_component_to_string( ) -def render_pyscript_template(file_path: str, uuid: str, root: str): +def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): """Inserts the user's code into the PyScript template using pattern matching.""" + from django.core.cache import caches + + from reactpy_django.config import REACTPY_CACHE + # Create a valid PyScript executor by replacing the template values executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) executor = executor.replace("return root()", f"return {root}()") - # Insert the user code into the template - user_code = Path(file_path).read_text(encoding="utf-8") - user_code = user_code.strip().replace("\t", " ") # Normalize the code text + # Fetch the user's PyScript code + all_file_contents: list[str] = [] + for file_path in file_paths: + # Try to get user code from cache + cache_key = create_cache_key("pyscript", file_path) + last_modified_time = os.stat(file_path).st_mtime + file_contents: str = caches[REACTPY_CACHE].get( + cache_key, version=int(last_modified_time) + ) + if file_contents: + all_file_contents.append(file_contents) + + # If not cached, read from file system + else: + file_contents = Path(file_path).read_text(encoding="utf-8").strip() + all_file_contents.append(file_contents) + caches[REACTPY_CACHE].set( + cache_key, file_contents, version=int(last_modified_time) + ) + + # Prepare the PyScript code block + user_code = "\n".join(all_file_contents) # Combine all user code + user_code = user_code.replace("\t", " ") # Normalize the text user_code = textwrap.indent(user_code, " ") # Add indentation to match template - executor = executor.replace(" def root(): ...", user_code) - return executor + # Insert the user code into the PyScript template + return executor.replace(" def root(): ...", user_code) def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: From b5b886237ad344f40b2a4613c4bd4d10842d9543 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:59:52 -0700 Subject: [PATCH 16/28] support multi-file pyscript components --- src/reactpy_django/templatetags/reactpy.py | 4 ++-- .../test_app/pyscript/components/multifile_child.py | 6 ++++++ .../test_app/pyscript/components/multifile_parent.py | 12 ++++++++++++ tests/test_app/templates/pyscript.html | 2 ++ tests/test_app/tests/test_components.py | 2 ++ 5 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/test_app/pyscript/components/multifile_child.py create mode 100644 tests/test_app/pyscript/components/multifile_parent.py diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index ad6e7b7c..bd871497 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -210,14 +210,14 @@ def validate_host(host: str): @register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) def pyscript_component( context: template.RequestContext, - file_path: str, + *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", ): uuid = uuid4().hex request: HttpRequest | None = context.get("request") initial = vdom_or_component_to_string(initial, request=request, uuid=uuid) - executor = render_pyscript_template(file_path, uuid, root) + executor = render_pyscript_template(file_paths, uuid, root) return { "pyscript_executor": executor, diff --git a/tests/test_app/pyscript/components/multifile_child.py b/tests/test_app/pyscript/components/multifile_child.py new file mode 100644 index 00000000..4658e8a2 --- /dev/null +++ b/tests/test_app/pyscript/components/multifile_child.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def child(): + return html.div({"id": "multifile-child"}, "Multifile child") diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py new file mode 100644 index 00000000..851f8361 --- /dev/null +++ b/tests/test_app/pyscript/components/multifile_parent.py @@ -0,0 +1,12 @@ +# pylint: disable=used-before-assignment +from typing import TYPE_CHECKING + +from reactpy import component, html + +if TYPE_CHECKING: + from .multifile_child import child + + +@component +def root(): + return html.div({"id": "multifile-parent"}, "Multifile root", child()) diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 424ffdb6..54d98d6b 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -18,6 +18,8 @@

ReactPy PyScript Test Page


{% pyscript_component "./test_app/pyscript/components/custom_root.py" root="main" %}
+ {% pyscript_component "./test_app/pyscript/components/multifile_parent.py" "./test_app/pyscript/components/multifile_child.py" %} +
{% pyscript_component "./test_app/pyscript/components/counter.py" %}
{% component "test_app.pyscript.components.server_side.parent" %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index c0af8126..1af5d09f 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -686,6 +686,8 @@ def test_pyscript_components(self): new_page.wait_for_selector("#hello-world-loading") new_page.wait_for_selector("#hello-world") new_page.wait_for_selector("#custom-root") + new_page.wait_for_selector("#multifile-parent") + new_page.wait_for_selector("#multifile-child") new_page.wait_for_selector("#counter") new_page.wait_for_selector("#counter pre[data-value='0']") From fc7186444ecb452a75a37dcb61662eb40f990f9d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 01:13:22 -0700 Subject: [PATCH 17/28] Add docstrings to the layout handler --- src/reactpy_django/pyscript/layout_handler.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 23acc4c5..56c5a79c 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -22,17 +22,23 @@ def __init__(self, uuid): @staticmethod def apply_update(update, root_model): + """Apply an update ReactPy's internal DOM model.""" if update["path"]: set_pointer(root_model, update["path"], update["model"]) else: root_model.update(update["model"]) def render(self, layout, model): + """Submit ReactPy's internal DOM model into the HTML DOM.""" container = js.document.getElementById(f"pyscript-{self.uuid}") + + # FIXME: The current implementation completely recreates the DOM on every render. + # This is not ideal, and should be optimized in the future. container.innerHTML = "" self.build_element_tree(layout, container, model) def build_element_tree(self, layout, parent, model): + """Recursively build an element tree, starting from the root component.""" if isinstance(model, str): parent.appendChild(js.document.createTextNode(model)) elif isinstance(model, dict): @@ -66,6 +72,8 @@ def build_element_tree(self, layout, parent, model): @staticmethod def create_event_handler(layout, element, event_name, event_handler_model): + """Create an event handler for an element. This function is used as an + adapter between ReactPy and browser events.""" target = event_handler_model["target"] def event_handler(*args): @@ -78,6 +86,9 @@ def event_handler(*args): @staticmethod def delete_old_workspaces(): + """To prevent memory leaks, we must delete all user generated Python code + whe it is no longer on the page. To do this, we compare what UUIDs exist on + the DOM, versus what UUIDs exist within the PyScript global interpreter.""" dom_workspaces = js.document.querySelectorAll(".pyscript") dom_uuids = {element.dataset.uuid for element in dom_workspaces} python_uuids = { @@ -105,6 +116,7 @@ def delete_old_workspaces(): ) async def run(self, workspace_function: Callable[[], ComponentType]): + """Run the layout handler. This function is main executor for all user generated code.""" self.delete_old_workspaces() root_model: dict = {} From 4066adb4e9d820459f9e62326481ecb1e3bb40d7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 01:49:14 -0700 Subject: [PATCH 18/28] import encapsulation --- .../pyscript/component_template.py | 1 - src/reactpy_django/pyscript/layout_handler.py | 21 ++++++++++++------- .../pyscript/components/multifile_parent.py | 1 - 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index d3eaaa5d..c26d1a57 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -1,4 +1,3 @@ -# pylint: disable=used-before-assignment from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 56c5a79c..bc2b7224 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -1,12 +1,5 @@ # mypy: disable-error-code=attr-defined import asyncio -from typing import Callable - -import js -from jsonpointer import set_pointer -from pyodide.ffi.wrappers import add_event_listener -from reactpy.core.layout import Layout -from reactpy.types import ComponentType class ReactPyLayoutHandler: @@ -23,6 +16,8 @@ def __init__(self, uuid): @staticmethod def apply_update(update, root_model): """Apply an update ReactPy's internal DOM model.""" + from jsonpointer import set_pointer + if update["path"]: set_pointer(root_model, update["path"], update["model"]) else: @@ -30,6 +25,8 @@ def apply_update(update, root_model): def render(self, layout, model): """Submit ReactPy's internal DOM model into the HTML DOM.""" + import js + container = js.document.getElementById(f"pyscript-{self.uuid}") # FIXME: The current implementation completely recreates the DOM on every render. @@ -39,6 +36,8 @@ def render(self, layout, model): def build_element_tree(self, layout, parent, model): """Recursively build an element tree, starting from the root component.""" + import js + if isinstance(model, str): parent.appendChild(js.document.createTextNode(model)) elif isinstance(model, dict): @@ -74,6 +73,8 @@ def build_element_tree(self, layout, parent, model): def create_event_handler(layout, element, event_name, event_handler_model): """Create an event handler for an element. This function is used as an adapter between ReactPy and browser events.""" + from pyodide.ffi.wrappers import add_event_listener + target = event_handler_model["target"] def event_handler(*args): @@ -89,6 +90,8 @@ def delete_old_workspaces(): """To prevent memory leaks, we must delete all user generated Python code whe it is no longer on the page. To do this, we compare what UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global interpreter.""" + import js + dom_workspaces = js.document.querySelectorAll(".pyscript") dom_uuids = {element.dataset.uuid for element in dom_workspaces} python_uuids = { @@ -115,8 +118,10 @@ def delete_old_workspaces(): f"Warning: Could not auto delete PyScript workspace {workspace_name}" ) - async def run(self, workspace_function: Callable[[], ComponentType]): + async def run(self, workspace_function): """Run the layout handler. This function is main executor for all user generated code.""" + from reactpy.core.layout import Layout + self.delete_old_workspaces() root_model: dict = {} diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py index 851f8361..48a1b1d8 100644 --- a/tests/test_app/pyscript/components/multifile_parent.py +++ b/tests/test_app/pyscript/components/multifile_parent.py @@ -1,4 +1,3 @@ -# pylint: disable=used-before-assignment from typing import TYPE_CHECKING from reactpy import component, html From eb72c4f0b9cb55939ae52bbb8040cf3277a94e40 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:22:01 -0700 Subject: [PATCH 19/28] add warning to router --- docs/src/reference/router.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index af5353e8..29bbcf15 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -18,6 +18,13 @@ A Single Page Application URL router, which is a variant of [`reactpy-router`](h URL router that enables the ability to conditionally render other components based on the client's current URL `#!python path`. +!!! warning "Pitfall" + + All pages where this component exists must have the same, or more permissive exposure within Django's [URL patterns](https://docs.djangoproject.com/en/5.0/topics/http/urls/#example). You can think of this component as a secondary, client-side router. Django still handles the primary server-side routes. + + We recommend creating a route with a wildcard `.*` to forward routes to ReactPy. For example... + `#!python re_path(r"^/router/.*$", my_reactpy_view)` + === "components.py" ```python From 53c0499bb2fdf2566946578a1cfac5c79977af23 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 20:04:17 -0700 Subject: [PATCH 20/28] component and template tag docs --- CHANGELOG.md | 14 +- README.md | 5 +- docs/examples/html/pyscript-component.html | 14 ++ .../html/pyscript-initial-object.html | 3 + .../html/pyscript-initial-string.html | 3 + .../html/pyscript-multiple-files.html | 15 ++ docs/examples/html/pyscript-root.html | 3 + .../html/pyscript-setup-config-object.html | 4 + .../html/pyscript-setup-config-string.html | 4 + .../html/pyscript-setup-dependencies.html | 4 + docs/examples/html/pyscript-setup.html | 6 + docs/examples/html/pyscript-ssr-parent.html | 14 ++ docs/examples/python/example/views.py | 2 +- .../pyscript-component-initial-object.py | 12 + .../pyscript-component-initial-string.py | 12 + .../pyscript-component-multiple-files-root.py | 12 + .../python/pyscript-component-root.py | 12 + docs/examples/python/pyscript-hello-world.py | 6 + .../python/pyscript-initial-object.py | 10 + docs/examples/python/pyscript-js-execution.py | 11 + .../python/pyscript-multiple-files-child.py | 6 + .../python/pyscript-multiple-files-root.py | 11 + docs/examples/python/pyscript-root.py | 6 + .../python/pyscript-setup-config-object.py | 9 + docs/examples/python/pyscript-ssr-child.py | 6 + docs/examples/python/pyscript-ssr-parent.py | 10 + docs/examples/python/template-tag-bad-view.py | 7 +- docs/src/assets/css/admonition.css | 137 ++++++------ docs/src/dictionary.txt | 2 + .../learn/add-reactpy-to-a-django-project.md | 2 +- docs/src/learn/your-first-component.md | 6 +- docs/src/reference/components.md | 104 +++++++++ docs/src/reference/template-tag.md | 207 ++++++++++++++++-- docs/src/reference/utils.md | 2 +- src/reactpy_django/components.py | 21 +- .../pyscript/component_template.py | 3 +- src/reactpy_django/templatetags/reactpy.py | 70 +++--- src/reactpy_django/utils.py | 26 +++ .../pyscript/components/server_side.py | 6 +- 39 files changed, 671 insertions(+), 136 deletions(-) create mode 100644 docs/examples/html/pyscript-component.html create mode 100644 docs/examples/html/pyscript-initial-object.html create mode 100644 docs/examples/html/pyscript-initial-string.html create mode 100644 docs/examples/html/pyscript-multiple-files.html create mode 100644 docs/examples/html/pyscript-root.html create mode 100644 docs/examples/html/pyscript-setup-config-object.html create mode 100644 docs/examples/html/pyscript-setup-config-string.html create mode 100644 docs/examples/html/pyscript-setup-dependencies.html create mode 100644 docs/examples/html/pyscript-setup.html create mode 100644 docs/examples/html/pyscript-ssr-parent.html create mode 100644 docs/examples/python/pyscript-component-initial-object.py create mode 100644 docs/examples/python/pyscript-component-initial-string.py create mode 100644 docs/examples/python/pyscript-component-multiple-files-root.py create mode 100644 docs/examples/python/pyscript-component-root.py create mode 100644 docs/examples/python/pyscript-hello-world.py create mode 100644 docs/examples/python/pyscript-initial-object.py create mode 100644 docs/examples/python/pyscript-js-execution.py create mode 100644 docs/examples/python/pyscript-multiple-files-child.py create mode 100644 docs/examples/python/pyscript-multiple-files-root.py create mode 100644 docs/examples/python/pyscript-root.py create mode 100644 docs/examples/python/pyscript-setup-config-object.py create mode 100644 docs/examples/python/pyscript-ssr-child.py create mode 100644 docs/examples/python/pyscript-ssr-parent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9b047f..36b08214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,13 +34,19 @@ Using the following categories, list your changes in this order: ## [Unreleased] +### Added + +- Client-side Python components can now be rendered via the new `pyscript_component` template tag +- Client-side components can be embedded into existing server-side components via `reactpy_django.components.pyscript_component`. +- You can now write Python code that runs within client browser via the `reactpy_django.html.pyscript` element. This is a viable substitution for most JavaScript code. + ### Changed - New syntax for `use_query` and `use_mutation` hooks. Here's a quick comparison of the changes: ```python - query = use_query(QueryOptions(thread_sensitive=True), get_items, value=123456, foo="bar") # Old - query = use_query(get_items, {"value":12356, "foo":"bar"}, thread_sensitive=True) # New + query = use_query(QueryOptions(thread_sensitive=True), get_items, foo="bar") # Old + query = use_query(get_items, {"foo":"bar"}, thread_sensitive=True) # New mutation = use_mutation(MutationOptions(thread_sensitive=True), remove_item) # Old mutation = use_mutation(remove_item, thread_sensitive=True) # New @@ -50,6 +56,10 @@ Using the following categories, list your changes in this order: - `QueryOptions` and `MutationOptions` have been removed. Their values are now passed direct into the hook. +### Fixed + +- Resolved a bug where Django-ReactPy would not properly detect `settings.py:DEBUG`. + ## [3.8.1] - 2024-05-07 ### Added diff --git a/README.md b/README.md index ce11472f..817e684b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ReactPy Django +# ReactPy-Django

@@ -21,6 +21,7 @@ [ReactPy-Django](https://github.com/reactive-python/reactpy-django) is used to add [ReactPy](https://reactpy.dev/) support to an existing **Django project**. This package also turbocharges ReactPy with features such as... - [SEO compatible rendering](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#reactpy_prerender) +- [Client-Side Python components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/#pyscript-component) - [Single page application (SPA) capabilities](https://reactive-python.github.io/reactpy-django/latest/reference/router/#django-router) - [Distributed computing](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#reactpy_default_hosts) - [Performance enhancements](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#performance-settings) @@ -82,7 +83,7 @@ def hello_world(recipient: str): -## [`my_app/templates/my-template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) +## [`my_app/templates/my_template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) diff --git a/docs/examples/html/pyscript-component.html b/docs/examples/html/pyscript-component.html new file mode 100644 index 00000000..3f21e3fa --- /dev/null +++ b/docs/examples/html/pyscript-component.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup %} + + + + {% pyscript_component "./example_project/my_app/components/hello_world.py" %} + + + diff --git a/docs/examples/html/pyscript-initial-object.html b/docs/examples/html/pyscript-initial-object.html new file mode 100644 index 00000000..0e0a35c3 --- /dev/null +++ b/docs/examples/html/pyscript-initial-object.html @@ -0,0 +1,3 @@ + + {% pyscript_component "./example_project/my_app/components/root.py" initial=my_initial_object %} + diff --git a/docs/examples/html/pyscript-initial-string.html b/docs/examples/html/pyscript-initial-string.html new file mode 100644 index 00000000..8e062d6a --- /dev/null +++ b/docs/examples/html/pyscript-initial-string.html @@ -0,0 +1,3 @@ + + {% pyscript_component "./example_project/my_app/components/root.py" initial="

Loading ...
" %} + diff --git a/docs/examples/html/pyscript-multiple-files.html b/docs/examples/html/pyscript-multiple-files.html new file mode 100644 index 00000000..1f9267a8 --- /dev/null +++ b/docs/examples/html/pyscript-multiple-files.html @@ -0,0 +1,15 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup %} + + + + {% pyscript_component "./example_project/my_app/components/root.py" + "./example_project/my_app/components/child.py" %} + + + diff --git a/docs/examples/html/pyscript-root.html b/docs/examples/html/pyscript-root.html new file mode 100644 index 00000000..e89a5369 --- /dev/null +++ b/docs/examples/html/pyscript-root.html @@ -0,0 +1,3 @@ + + {% pyscript_component "./example_project/my_app/components/main.py" root="main" %} + diff --git a/docs/examples/html/pyscript-setup-config-object.html b/docs/examples/html/pyscript-setup-config-object.html new file mode 100644 index 00000000..70b408b1 --- /dev/null +++ b/docs/examples/html/pyscript-setup-config-object.html @@ -0,0 +1,4 @@ + + ReactPy + {% pyscript_setup config=my_config_object %} + diff --git a/docs/examples/html/pyscript-setup-config-string.html b/docs/examples/html/pyscript-setup-config-string.html new file mode 100644 index 00000000..d89e1ad7 --- /dev/null +++ b/docs/examples/html/pyscript-setup-config-string.html @@ -0,0 +1,4 @@ + + ReactPy + {% pyscript_setup config="{'experimental_create_proxy':'auto'}" %} + diff --git a/docs/examples/html/pyscript-setup-dependencies.html b/docs/examples/html/pyscript-setup-dependencies.html new file mode 100644 index 00000000..f982b8fb --- /dev/null +++ b/docs/examples/html/pyscript-setup-dependencies.html @@ -0,0 +1,4 @@ + + ReactPy + {% pyscript_setup "dill==0.3.5" "markdown<=3.6.0" "nest_asyncio" "titlecase" %} + diff --git a/docs/examples/html/pyscript-setup.html b/docs/examples/html/pyscript-setup.html new file mode 100644 index 00000000..20bb2b09 --- /dev/null +++ b/docs/examples/html/pyscript-setup.html @@ -0,0 +1,6 @@ +{% load reactpy %} + + + ReactPy + {% pyscript_setup %} + diff --git a/docs/examples/html/pyscript-ssr-parent.html b/docs/examples/html/pyscript-ssr-parent.html new file mode 100644 index 00000000..bf0f47ae --- /dev/null +++ b/docs/examples/html/pyscript-ssr-parent.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup %} + + + + {% component "example_project.my_app.components.server_side_component" %} + + + diff --git a/docs/examples/python/example/views.py b/docs/examples/python/example/views.py index a8ed7fdb..23e21130 100644 --- a/docs/examples/python/example/views.py +++ b/docs/examples/python/example/views.py @@ -2,4 +2,4 @@ def index(request): - return render(request, "my-template.html") + return render(request, "my_template.html") diff --git a/docs/examples/python/pyscript-component-initial-object.py b/docs/examples/python/pyscript-component-initial-object.py new file mode 100644 index 00000000..222a568b --- /dev/null +++ b/docs/examples/python/pyscript-component-initial-object.py @@ -0,0 +1,12 @@ +from reactpy import component, html +from reactpy_django.components import pyscript_component + + +@component +def server_side_component(): + return html.div( + pyscript_component( + "./example_project/my_app/components/root.py", + initial=html.div("Loading ..."), + ), + ) diff --git a/docs/examples/python/pyscript-component-initial-string.py b/docs/examples/python/pyscript-component-initial-string.py new file mode 100644 index 00000000..664b9f9b --- /dev/null +++ b/docs/examples/python/pyscript-component-initial-string.py @@ -0,0 +1,12 @@ +from reactpy import component, html +from reactpy_django.components import pyscript_component + + +@component +def server_side_component(): + return html.div( + pyscript_component( + "./example_project/my_app/components/root.py", + initial="
Loading ...
", + ), + ) diff --git a/docs/examples/python/pyscript-component-multiple-files-root.py b/docs/examples/python/pyscript-component-multiple-files-root.py new file mode 100644 index 00000000..776b26b2 --- /dev/null +++ b/docs/examples/python/pyscript-component-multiple-files-root.py @@ -0,0 +1,12 @@ +from reactpy import component, html +from reactpy_django.components import pyscript_component + + +@component +def server_side_component(): + return html.div( + pyscript_component( + "./example_project/my_app/components/root.py", + "./example_project/my_app/components/child.py", + ), + ) diff --git a/docs/examples/python/pyscript-component-root.py b/docs/examples/python/pyscript-component-root.py new file mode 100644 index 00000000..9880b740 --- /dev/null +++ b/docs/examples/python/pyscript-component-root.py @@ -0,0 +1,12 @@ +from reactpy import component, html +from reactpy_django.components import pyscript_component + + +@component +def server_side_component(): + return html.div( + pyscript_component( + "./example_project/my_app/components/main.py", + root="main", + ), + ) diff --git a/docs/examples/python/pyscript-hello-world.py b/docs/examples/python/pyscript-hello-world.py new file mode 100644 index 00000000..d5737421 --- /dev/null +++ b/docs/examples/python/pyscript-hello-world.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def root(): + return html.div("Hello, World!") diff --git a/docs/examples/python/pyscript-initial-object.py b/docs/examples/python/pyscript-initial-object.py new file mode 100644 index 00000000..1742ff87 --- /dev/null +++ b/docs/examples/python/pyscript-initial-object.py @@ -0,0 +1,10 @@ +from django.shortcuts import render +from reactpy import html + + +def index(request): + return render( + request, + "my_template.html", + context={"my_initial_object": html.div("Loading ...")}, + ) diff --git a/docs/examples/python/pyscript-js-execution.py b/docs/examples/python/pyscript-js-execution.py new file mode 100644 index 00000000..a96ef65b --- /dev/null +++ b/docs/examples/python/pyscript-js-execution.py @@ -0,0 +1,11 @@ +import js +from reactpy import component, html + + +@component +def root(): + + def onClick(event): + js.document.title = "New window title" + + return html.button({"onClick": onClick}, "Click Me!") diff --git a/docs/examples/python/pyscript-multiple-files-child.py b/docs/examples/python/pyscript-multiple-files-child.py new file mode 100644 index 00000000..73dbb189 --- /dev/null +++ b/docs/examples/python/pyscript-multiple-files-child.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def child_component(): + return html.div("This is a child component from a different file.") diff --git a/docs/examples/python/pyscript-multiple-files-root.py b/docs/examples/python/pyscript-multiple-files-root.py new file mode 100644 index 00000000..dc17e7ad --- /dev/null +++ b/docs/examples/python/pyscript-multiple-files-root.py @@ -0,0 +1,11 @@ +from typing import TYPE_CHECKING + +from reactpy import component, html + +if TYPE_CHECKING: + from .child import child_component + + +@component +def root(): + return html.div("This text is from the root component.", child_component()) diff --git a/docs/examples/python/pyscript-root.py b/docs/examples/python/pyscript-root.py new file mode 100644 index 00000000..f39fd01e --- /dev/null +++ b/docs/examples/python/pyscript-root.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def main(): + return html.div("Hello, World!") diff --git a/docs/examples/python/pyscript-setup-config-object.py b/docs/examples/python/pyscript-setup-config-object.py new file mode 100644 index 00000000..85db2751 --- /dev/null +++ b/docs/examples/python/pyscript-setup-config-object.py @@ -0,0 +1,9 @@ +from django.shortcuts import render + + +def index(request): + return render( + request, + "my_template.html", + context={"my_config_object": {"experimental_create_proxy": "auto"}}, + ) diff --git a/docs/examples/python/pyscript-ssr-child.py b/docs/examples/python/pyscript-ssr-child.py new file mode 100644 index 00000000..d2566c88 --- /dev/null +++ b/docs/examples/python/pyscript-ssr-child.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def root(): + return html.div("This text is from my client-side component") diff --git a/docs/examples/python/pyscript-ssr-parent.py b/docs/examples/python/pyscript-ssr-parent.py new file mode 100644 index 00000000..b51aa110 --- /dev/null +++ b/docs/examples/python/pyscript-ssr-parent.py @@ -0,0 +1,10 @@ +from reactpy import component, html +from reactpy_django.components import pyscript_component + + +@component +def server_side_component(): + return html.div( + "This text is from my server-side component", + pyscript_component("./example_project/my_app/components/root.py"), + ) diff --git a/docs/examples/python/template-tag-bad-view.py b/docs/examples/python/template-tag-bad-view.py index 00d0d9f7..ef16c845 100644 --- a/docs/examples/python/template-tag-bad-view.py +++ b/docs/examples/python/template-tag-bad-view.py @@ -2,5 +2,8 @@ def example_view(request): - context_vars = {"my_variable": "example_project.my_app.components.hello_world"} - return render(request, "my-template.html", context_vars) + return render( + request, + "my_template.html", + context={"my_variable": "example_project.my_app.components.hello_world"}, + ) diff --git a/docs/src/assets/css/admonition.css b/docs/src/assets/css/admonition.css index 8b3f06ef..c93892a8 100644 --- a/docs/src/assets/css/admonition.css +++ b/docs/src/assets/css/admonition.css @@ -1,45 +1,45 @@ [data-md-color-scheme="slate"] { - --admonition-border-color: transparent; - --admonition-expanded-border-color: rgba(255, 255, 255, 0.1); - --note-bg-color: rgba(43, 110, 98, 0.2); - --terminal-bg-color: #0c0c0c; - --terminal-title-bg-color: #000; - --deep-dive-bg-color: rgba(43, 52, 145, 0.2); - --you-will-learn-bg-color: #353a45; - --pitfall-bg-color: rgba(182, 87, 0, 0.2); + --admonition-border-color: transparent; + --admonition-expanded-border-color: rgba(255, 255, 255, 0.1); + --note-bg-color: rgba(43, 110, 98, 0.2); + --terminal-bg-color: #0c0c0c; + --terminal-title-bg-color: #000; + --deep-dive-bg-color: rgba(43, 52, 145, 0.2); + --you-will-learn-bg-color: #353a45; + --pitfall-bg-color: rgba(182, 87, 0, 0.2); } [data-md-color-scheme="default"] { - --admonition-border-color: rgba(0, 0, 0, 0.08); - --admonition-expanded-border-color: var(--admonition-border-color); - --note-bg-color: rgb(244, 251, 249); - --terminal-bg-color: rgb(64, 71, 86); - --terminal-title-bg-color: rgb(35, 39, 47); - --deep-dive-bg-color: rgb(243, 244, 253); - --you-will-learn-bg-color: rgb(246, 247, 249); - --pitfall-bg-color: rgb(254, 245, 231); + --admonition-border-color: rgba(0, 0, 0, 0.08); + --admonition-expanded-border-color: var(--admonition-border-color); + --note-bg-color: rgb(244, 251, 249); + --terminal-bg-color: rgb(64, 71, 86); + --terminal-title-bg-color: rgb(35, 39, 47); + --deep-dive-bg-color: rgb(243, 244, 253); + --you-will-learn-bg-color: rgb(246, 247, 249); + --pitfall-bg-color: rgb(254, 245, 231); } .md-typeset details, .md-typeset .admonition { - border-color: var(--admonition-border-color) !important; - box-shadow: none; + border-color: var(--admonition-border-color) !important; + box-shadow: none; } .md-typeset :is(.admonition, details) { - margin: 0.55em 0; + margin: 0 0; } .md-typeset .admonition { - font-size: 0.7rem; + font-size: 0.7rem; } .md-typeset .admonition:focus-within, .md-typeset details:focus-within { - box-shadow: none !important; + box-shadow: none !important; } .md-typeset details[open] { - border-color: var(--admonition-expanded-border-color) !important; + border-color: var(--admonition-expanded-border-color) !important; } /* @@ -47,24 +47,24 @@ Admonition: "summary" React Name: "You will learn" */ .md-typeset .admonition.summary { - background: var(--you-will-learn-bg-color); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; + background: var(--you-will-learn-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; } .md-typeset .summary .admonition-title { - font-size: 1rem; - background: transparent; - padding-left: 0.6rem; - padding-bottom: 0; + font-size: 1rem; + background: transparent; + padding-left: 0.6rem; + padding-bottom: 0; } .md-typeset .summary .admonition-title:before { - display: none; + display: none; } .md-typeset .admonition.summary { - border-color: #ffffff17 !important; + border-color: #ffffff17 !important; } /* @@ -72,21 +72,21 @@ Admonition: "abstract" React Name: "Note" */ .md-typeset .admonition.abstract { - background: var(--note-bg-color); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; + background: var(--note-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; } .md-typeset .abstract .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(68, 172, 153); + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(68, 172, 153); } .md-typeset .abstract .admonition-title:before { - font-size: 1.1rem; - background: rgb(68, 172, 153); + font-size: 1.1rem; + background: rgb(68, 172, 153); } /* @@ -94,21 +94,21 @@ Admonition: "warning" React Name: "Pitfall" */ .md-typeset .admonition.warning { - background: var(--pitfall-bg-color); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; + background: var(--pitfall-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; } .md-typeset .warning .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(219, 125, 39); + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(219, 125, 39); } .md-typeset .warning .admonition-title:before { - font-size: 1.1rem; - background: rgb(219, 125, 39); + font-size: 1.1rem; + background: rgb(219, 125, 39); } /* @@ -116,21 +116,21 @@ Admonition: "info" React Name: "Deep Dive" */ .md-typeset .admonition.info { - background: var(--deep-dive-bg-color); - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; + background: var(--deep-dive-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; } .md-typeset .info .admonition-title { - font-size: 1rem; - background: transparent; - padding-bottom: 0; - color: rgb(136, 145, 236); + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(136, 145, 236); } .md-typeset .info .admonition-title:before { - font-size: 1.1rem; - background: rgb(136, 145, 236); + font-size: 1.1rem; + background: rgb(136, 145, 236); } /* @@ -138,23 +138,24 @@ Admonition: "example" React Name: "Terminal" */ .md-typeset .admonition.example { - background: var(--terminal-bg-color); - border-radius: 0.4rem; - overflow: hidden; - border: none; + background: var(--terminal-bg-color); + border-radius: 0.4rem; + overflow: hidden; + border: none; + margin: 0.5rem 0; } .md-typeset .example .admonition-title { - background: var(--terminal-title-bg-color); - color: rgb(246, 247, 249); + background: var(--terminal-title-bg-color); + color: rgb(246, 247, 249); } .md-typeset .example .admonition-title:before { - background: rgb(246, 247, 249); + background: rgb(246, 247, 249); } .md-typeset .admonition.example code { - background: transparent; - color: #fff; - box-shadow: none; + background: transparent; + color: #fff; + box-shadow: none; } diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index dee45011..66265e78 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -39,3 +39,5 @@ misconfigurations backhaul sublicense broadcasted +hello_world +my_template diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md index a0cca013..dd258737 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -125,6 +125,6 @@ Prefer a quick summary? Read the **At a Glance** section below. --- - **`my_app/templates/my-template.html`** + **`my_app/templates/my_template.html`** {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md index b0749c41..08df6a57 100644 --- a/docs/src/learn/your-first-component.md +++ b/docs/src/learn/your-first-component.md @@ -43,7 +43,7 @@ Within this file, you can define your component functions using ReactPy's `#!pyt We recommend creating a `components.py` for small **Django apps**. If your app has a lot of components, you should consider breaking them apart into individual modules such as `components/navbar.py`. - Ultimately, components are referenced by Python dotted path in `my-template.html` ([_see next step_](#embedding-in-a-template)). This path must be valid to Python's `#!python importlib`. + Ultimately, components are referenced by Python dotted path in `my_template.html` ([_see next step_](#embedding-in-a-template)). This path must be valid to Python's `#!python importlib`. ??? question "What does the decorator actually do?" @@ -62,7 +62,7 @@ In your **Django app**'s HTML template, you can now embed your ReactPy component Additionally, you can pass in `#!python args` and `#!python kwargs` into your component function. After reading the code below, pay attention to how the function definition for `#!python hello_world` ([_from the previous step_](#defining-a-component)) accepts a `#!python recipient` argument. -=== "my-template.html" +=== "my_template.html" {% include-markdown "../../../README.md" start="" end="" %} @@ -76,7 +76,7 @@ Additionally, you can pass in `#!python args` and `#!python kwargs` into your co ## Setting up a Django view -Within your **Django app**'s `views.py` file, you will need to [create a view function](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) to render the HTML template `my-template.html` ([_from the previous step_](#embedding-in-a-template)). +Within your **Django app**'s `views.py` file, you will need to [create a view function](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) to render the HTML template `my_template.html` ([_from the previous step_](#embedding-in-a-template)). === "views.py" diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index aa0e75d4..d535b6b1 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -8,6 +8,110 @@ We supply some pre-designed that components can be used to help simplify develop --- +## PyScript Component + +This component can be used to insert any number of client-side ReactPy components onto your page. + +This is an embeddable version of the [`#!jinja {% pyscript_component %}` template tag](./template-tag.md#pyscript-component). + +{% include-markdown "../reference/template-tag.md" start="" end="" %} + +=== "components.py" + + ```python + {% include "../../examples/python/pyscript-ssr-parent.py" %} + ``` + +=== "root.py" + + ```python + {% include "../../examples/python/pyscript-ssr-child.py" %} + ``` + +=== "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-ssr-parent.html" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A | + | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | + | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | + +??? warning "You must call `pyscript_setup` in your Django template before using this tag!" + + This component requires the use of the [`#!jinja {% pyscript_setup %}` template tag](./template-tag.md#pyscript-setup) to work correctly. + + ```jinja + {% include "../../examples/html/pyscript-setup.html" %} + ``` + +{% include-markdown "../reference/template-tag.md" start="" end="" %} + +{% include-markdown "../reference/template-tag.md" start="" end="" trailing-newlines=false preserve-includer-indent=false %} + + === "components.py" + + ```python + {% include "../../examples/python/pyscript-component-multiple-files-root.py" %} + ``` + + === "root.py" + + ```python + {% include "../../examples/python/pyscript-multiple-files-root.py" %} + ``` + + === "child.py" + + ```python + {% include "../../examples/python/pyscript-multiple-files-child.py" %} + ``` + +??? question "How do I display something while the component is loading?" + + You can configure the `#!python initial` keyword to display HTML while your PyScript component is loading. + + The value for `#!python initial` is most commonly be a `#!python reactpy.html` snippet or a non-interactive `#!python @component`. + + === "components.py" + + ```python + {% include "../../examples/python/pyscript-component-initial-object.py" %} + ``` + + However, you can also use a string containing raw HTML. + + === "components.py" + + ```python + {% include "../../examples/python/pyscript-component-initial-string.py" %} + ``` + +??? question "Can I use a different name for my root component?" + + Yes, you can use the `#!python root` keyword to specify a different name for your root function. + + === "components.py" + + ```python + {% include "../../examples/python/pyscript-component-root.py" %} + ``` + + === "main.py" + + ```python + {% include "../../examples/python/pyscript-root.py" %} + ``` + +--- + ## View To Component Automatically convert a Django view into a component. diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 759aa8cf..f5cf8bf7 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -14,7 +14,7 @@ This template tag can be used to insert any number of ReactPy components onto yo Each component loaded via this template tag will receive a dedicated WebSocket connection to the server. -=== "my-template.html" +=== "my_template.html" {% include-markdown "../../../README.md" start="" end="" %} @@ -33,23 +33,17 @@ Each component loaded via this template tag will receive a dedicated WebSocket c | `#!python offline` | `#!python str` | The dotted path to a component that will be displayed if your root component loses connection to the server. Keep in mind, this `offline` component will be non-interactive (hooks won't operate). | `#!python ""` | | `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A | - **Returns** - - | Type | Description | - | --- | --- | - | `#!python Component` | A ReactPy component. | - ??? warning "Do not use context variables for the component path" The ReactPy component finder requires that your component path is a string. - **Do not** use Django template/context variables for the component path. Failure to follow this warning can result in unexpected behavior, such as components that will not render. + **Do not** use Django template/context variables for the component path. Failure to follow this warning will result in components that will not render. For example, **do not** do the following: - === "my-template.html" + === "my_template.html" ```jinja @@ -75,7 +69,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c You can add as many components to a webpage as needed by using the template tag multiple times. Retrofitting legacy sites to use ReactPy will typically involve many components on one page. - === "my-template.html" + === "my_template.html" ```jinja {% load reactpy %} @@ -99,7 +93,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c You can use any combination of `#!python *args`/`#!python **kwargs` in your template tag. - === "my-template.html" + === "my_template.html" ```jinja {% component "example_project.my_app.components.frog_greeter" 123 "Mr. Froggles" species="Grey Treefrog" %} @@ -115,7 +109,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c Yes! This is most commonly done through [`settings.py:REACTPY_HOSTS`](../reference/settings.md#reactpy_default_hosts). However, you can use the `#!python host` keyword to render components on a specific ASGI server. - === "my-template.html" + === "my_template.html" ```jinja ... @@ -135,7 +129,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c This is most commonly done through [`settings.py:REACTPY_PRERENDER`](../reference/settings.md#reactpy_prerender). However, you can use the `#!python prerender` keyword to pre-render a specific component. - === "my-template.html" + === "my_template.html" ```jinja ... @@ -143,11 +137,11 @@ Each component loaded via this template tag will receive a dedicated WebSocket c ... ``` -??? question "How do I show something when the client disconnects?" +??? question "How do I display something when the client disconnects?" You can use the `#!python offline` keyword to display a specific component when the client disconnects from the server. - === "my-template.html" + === "my_template.html" ```jinja ... @@ -156,3 +150,186 @@ Each component loaded via this template tag will receive a dedicated WebSocket c ``` _Note: The `#!python offline` component will be non-interactive (hooks won't operate)._ + +## PyScript Component + +This template tag can be used to insert any number of **client-side** ReactPy components onto your page. + + + +By default, the only dependencies available are the standard Python library, `pyscript`, `pyodide`, `reactpy` core. + +Your PyScript component file requires a `#!python def root()` component to function as the entry point. + + + +!!! warning "Pitfall" + + Your provided Python file is loaded directly into the client (web browser) **as raw text**, and ran using a PyScript interpreter. Be cautious about what you include in your Python file. + + As a result of running client-side, Python packages within your local environment (such as those installed via `pip install ...`) are **not accessible** within PyScript components. + +=== "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-component.html" %} + ``` + +=== "hello_world.py" + + ```python + {% include "../../examples/python/pyscript-hello-world.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A | + | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | + | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | + + + +??? question "How do I execute JavaScript within PyScript components?" + + PyScript components have the ability to directly execute JavaScript using the [`pyodide` `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) or [`pyscript` foreign function interface](https://docs.pyscript.net/2024.6.1/user-guide/dom/). + + === "root.py" + + ```python + {% include "../../examples/python/pyscript-js-execution.py" %} + ``` + + + + + +??? question "Does my entire component need to be contained in one file?" + + PyScript components do not have access to your local disk, and thus cannot `#!python import` any local Python modules. + + To bypass this, you can declare multiple file paths. These files will automatically combined during processing. + + Here is how we recommend doing that while retaining type hints. + + + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-multiple-files.html" %} + ``` + + === "root.py" + + ```python + {% include "../../examples/python/pyscript-multiple-files-root.py" %} + ``` + + === "child.py" + + ```python + {% include "../../examples/python/pyscript-multiple-files-child.py" %} + ``` + +??? question "How do I display something while the component is loading?" + + You can configure the `#!python initial` keyword to display HTML while your PyScript component is loading. + + The value for `#!python initial` is most commonly be a string containing raw HTML. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-initial-string.html" %} + ``` + + However, you can also insert a `#!python reactpy.html` snippet or a non-interactive `#!python @component` via template context. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-initial-object.html" %} + ``` + + === "views.py" + + ```python + {% include "../../examples/python/pyscript-initial-object.py" %} + ``` + +??? question "Can I use a different name for my root component?" + + Yes, you can use the `#!python root` keyword to specify a different name for your root function. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-root.html" %} + ``` + + === "main.py" + + ```python + {% include "../../examples/python/pyscript-root.py" %} + ``` + +## PyScript Setup + +This template tag configures the current page to be able to run `pyscript` by loading JavaScript, CSS, and settings values. + +You can optionally include a list of Python packages to install within the PyScript environment, or a [PyScript configuration dictionary](https://docs.pyscript.net/2024.6.1/user-guide/configuration/). + +=== "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup.html" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python *dependencies` | `#!python str` | Dependencies that need to be loaded on the page for your PyScript components. Each dependency must be contained within it's own string and written in Python requirements file syntax. | N/A | + | `#!python config` | `#!python str | dict` | A JSON string or Python dictionary containing PyScript configuration values. | `#!python ""` | + +??? question "How do I define dependencies for my PyScript component?" + + Dependencies must be available on [`pypi`](https://pypi.org/), written in pure Python, and declared in your `#!jinja {% pyscript_setup %}` block using Python requirements file syntax. + + These dependencies are automatically downloaded and installed into the PyScript client-side environment when the page is loaded. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup-dependencies.html" %} + ``` + +??? question "How do I modify the `pyscript` default configuration?" + + You can modify the default [PyScript configuration](https://docs.pyscript.net/2024.6.2/user-guide/configuration/) by providing a value to the `#!python config` keyword. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup-config-string.html" %} + ``` + + While this value is most commonly a JSON string, Python dictionary objects are also supported. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup-config-object.html" %} + ``` + + === "views.py" + + ```python + {% include "../../examples/python/pyscript-setup-config-object.py" %} + ``` diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index 461d9df5..6590012c 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -76,7 +76,7 @@ Typically, this function is automatically called on all components contained wit For security reasons, ReactPy requires all root components to be registered. However, all components contained within Django templates are automatically registered. - This function is needed when you have configured your [`host`](../reference/template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. + This function is commonly needed when you have configured your [`host`](../reference/template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. --- diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 4018f6f0..3d72c596 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -154,13 +154,24 @@ def django_js(static_path: str, key: Key | None = None): return _django_js(static_path=static_path, key=key) -def python_to_pyscript( - file_path: str, +def pyscript_component( + *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", ): - return _python_to_pyscript( - file_path, + """ + Args: + file_paths: File path to your client-side component. If multiple paths are \ + provided, the contents are automatically merged. + + Kwargs: + initial: The initial HTML that is displayed prior to the PyScript component \ + loads. This can either be a string containing raw HTML, a \ + `#!python reactpy.html` snippet, or a non-interactive component. + root: The name of the root component function. + """ + return _pyscript_component( + *file_paths, initial=initial, root=root, ) @@ -305,7 +316,7 @@ def _cached_static_contents(static_path: str) -> str: @component -def _python_to_pyscript( +def _pyscript_component( *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index c26d1a57..0ebfe706 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -22,6 +22,5 @@ def root(): ... return root() -# PyScript allows top-level await, which allows us to not throw errors on components -# that terminate early (such as hook-less components) +# Create a task to run the user's component workspace task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID)) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index bd871497..4e808ecf 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -3,14 +3,12 @@ from logging import getLogger from uuid import uuid4 -import dill as pickle from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict from reactpy_django import config as reactpy_config -from reactpy_django import models from reactpy_django.exceptions import ( ComponentCarrierError, ComponentDoesNotExistError, @@ -18,14 +16,15 @@ InvalidHostError, OfflineComponentMissing, ) -from reactpy_django.types import ComponentParams from reactpy_django.utils import ( PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, prerender_component, render_pyscript_template, + save_component_params, strtobool, validate_component_args, + validate_host, vdom_or_component_to_string, ) @@ -181,32 +180,6 @@ def component( } -def failure_context(dotted_path: str, error: Exception): - return { - "reactpy_failure": True, - "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, - "reactpy_dotted_path": dotted_path, - "reactpy_error": type(error).__name__, - } - - -def save_component_params(args, kwargs, uuid): - params = ComponentParams(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) - model.full_clean() - model.save() - - -def validate_host(host: str): - if "://" in host: - protocol = host.split("://")[0] - msg = ( - f"Invalid host provided to component. Contains a protocol '{protocol}://'." - ) - _logger.error(msg) - raise InvalidHostError(msg) - - @register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) def pyscript_component( context: template.RequestContext, @@ -214,6 +187,22 @@ def pyscript_component( initial: str | VdomDict | ComponentType = "", root: str = "root", ): + """ + Args: + file_paths: File path to your client-side component. If multiple paths are \ + provided, the contents are automatically merged. + + Kwargs: + initial: The initial HTML that is displayed prior to the PyScript component \ + loads. This can either be a string containing raw HTML, a \ + `#!python reactpy.html` snippet, or a non-interactive component. + root: The name of the root component function. + """ + if not file_paths: + raise ValueError( + "At least one file path must be provided to the 'pyscript_component' tag." + ) + uuid = uuid4().hex request: HttpRequest | None = context.get("request") initial = vdom_or_component_to_string(initial, request=request, uuid=uuid) @@ -228,11 +217,30 @@ def pyscript_component( @register.inclusion_tag("reactpy/pyscript_setup.html") def pyscript_setup( - *extra_packages: str, + *dependencies: str, config: str | dict = "", ): + """ + Args: + dependencies: Dependencies that need to be loaded on the page for \ + your PyScript components. Each dependency must be contained \ + within it's own string and written in Python requirements file syntax. + + Kwargs: + config: A JSON string or Python dictionary containing PyScript \ + configuration values. + """ return { - "pyscript_config": extend_pyscript_config(config, extra_packages), + "pyscript_config": extend_pyscript_config(config, dependencies), "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, } + + +def failure_context(dotted_path: str, error: Exception): + return { + "reactpy_failure": True, + "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + "reactpy_dotted_path": dotted_path, + "reactpy_error": type(error).__name__, + } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index cd479a1d..e1416c9a 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Mapping, Sequence from uuid import UUID, uuid4 +import dill import jsonpointer import orjson import reactpy @@ -35,6 +36,7 @@ from reactpy_django.exceptions import ( ComponentDoesNotExistError, ComponentParamError, + InvalidHostError, ViewDoesNotExistError, ) @@ -508,3 +510,27 @@ def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: elif isinstance(config, dict): pyscript_config.update(config) return orjson.dumps(pyscript_config).decode("utf-8") + + +def save_component_params(args, kwargs, uuid) -> None: + """Saves the component parameters to the database. + This is used within our template tag in order to propogate + the parameters between the HTTP and WebSocket stack.""" + from reactpy_django import models + from reactpy_django.types import ComponentParams + + params = ComponentParams(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=dill.dumps(params)) + model.full_clean() + model.save() + + +def validate_host(host: str) -> None: + """Validates the host string to ensure it does not contain a protocol.""" + if "://" in host: + protocol = host.split("://")[0] + msg = ( + f"Invalid host provided to component. Contains a protocol '{protocol}://'." + ) + _logger.error(msg) + raise InvalidHostError(msg) diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py index b26ef558..fe31d527 100644 --- a/tests/test_app/pyscript/components/server_side.py +++ b/tests/test_app/pyscript/components/server_side.py @@ -1,12 +1,12 @@ from reactpy import component, html, use_state -from reactpy_django.components import python_to_pyscript +from reactpy_django.components import pyscript_component @component def parent(): return html.div( {"id": "parent"}, - python_to_pyscript("./test_app/pyscript/components/child.py"), + pyscript_component("./test_app/pyscript/components/child.py"), ) @@ -29,5 +29,5 @@ def parent_toggle(): {"onClick": lambda x: set_state(not state)}, "Click to show/hide", ), - python_to_pyscript("./test_app/pyscript/components/child.py"), + pyscript_component("./test_app/pyscript/components/child.py"), ) From 577b83183e980c5c0111352b5963a221c886886f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 20:23:50 -0700 Subject: [PATCH 21/28] html primitive docs --- docs/examples/html/pyscript-tag.html | 14 +++++++++++++ docs/examples/python/pyscript-tag.py | 15 ++++++++++++++ docs/mkdocs.yml | 1 + docs/src/reference/components.md | 14 +++++++++---- docs/src/reference/html.md | 31 ++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 docs/examples/html/pyscript-tag.html create mode 100644 docs/examples/python/pyscript-tag.py create mode 100644 docs/src/reference/html.md diff --git a/docs/examples/html/pyscript-tag.html b/docs/examples/html/pyscript-tag.html new file mode 100644 index 00000000..6ca71085 --- /dev/null +++ b/docs/examples/html/pyscript-tag.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup %} + + + + {% component "example_project.my_app.components.server_side_component.py" %} + + + diff --git a/docs/examples/python/pyscript-tag.py b/docs/examples/python/pyscript-tag.py new file mode 100644 index 00000000..6455e9da --- /dev/null +++ b/docs/examples/python/pyscript-tag.py @@ -0,0 +1,15 @@ +from reactpy import component, html +from reactpy_django.html import pyscript + +example_source_code = """ +import js + +js.console.log("Hello, World!") +""" + + +@component +def server_side_component(): + return html.div( + pyscript(example_source_code.strip()), + ) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index bee85cc1..e4159640 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -7,6 +7,7 @@ nav: - Reference: - Components: reference/components.md - Hooks: reference/hooks.md + - HTML: reference/html.md - URL Router: reference/router.md - Decorators: reference/decorators.md - Utilities: reference/utils.md diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index d535b6b1..170bcf23 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -44,13 +44,19 @@ This is an embeddable version of the [`#!jinja {% pyscript_component %}` templat | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | + + ??? warning "You must call `pyscript_setup` in your Django template before using this tag!" - This component requires the use of the [`#!jinja {% pyscript_setup %}` template tag](./template-tag.md#pyscript-setup) to work correctly. + This requires using of the [`#!jinja {% pyscript_setup %}` template tag](./template-tag.md#pyscript-setup) to initialize PyScript on the client. - ```jinja - {% include "../../examples/html/pyscript-setup.html" %} - ``` + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup.html" %} + ``` + + {% include-markdown "../reference/template-tag.md" start="" end="" %} diff --git a/docs/src/reference/html.md b/docs/src/reference/html.md new file mode 100644 index 00000000..fd63c033 --- /dev/null +++ b/docs/src/reference/html.md @@ -0,0 +1,31 @@ +## Overview + +

+ +We supply some pre-generated that HTML nodes can be used to help simplify development. + +

+ +--- + +## PyScript + +Primitive HTML tag that is leveraged by [`reactpy_django.components.pyscript_component`](./components.md#pyscript-component). + +This can be used as an alternative to the `#!python reactpy.html.script` tag to execute JavaScript and run client-side Python code. + +Additionally, this tag functions identically to any other tag contained within `#!python reactpy.html`, and can be used in the same way. + +=== "components.py" + + ```python + {% include "../../examples/python/pyscript-tag.py" %} + ``` + +=== "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-tag.html" %} + ``` + +{% include-markdown "../reference/components.md" start="" end="" %} From f493fa240bddc56389e84dde700aba28bcc6dfcd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 20:26:44 -0700 Subject: [PATCH 22/28] fix docs warning --- docs/src/reference/components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 170bcf23..41b7f8d4 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -12,7 +12,7 @@ We supply some pre-designed that components can be used to help simplify develop This component can be used to insert any number of client-side ReactPy components onto your page. -This is an embeddable version of the [`#!jinja {% pyscript_component %}` template tag](./template-tag.md#pyscript-component). +This allows you to embedded PyScript components within traditional ReactPy components. {% include-markdown "../reference/template-tag.md" start="" end="" %} From 95aa90cc2a6fb355d48f843cd879987d8cd37078 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 20:27:31 -0700 Subject: [PATCH 23/28] simplify desc --- docs/src/reference/components.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 41b7f8d4..aaeabba7 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -10,9 +10,7 @@ We supply some pre-designed that components can be used to help simplify develop ## PyScript Component -This component can be used to insert any number of client-side ReactPy components onto your page. - -This allows you to embedded PyScript components within traditional ReactPy components. +This allows you to embedded any number of client-side PyScript components within traditional ReactPy components. {% include-markdown "../reference/template-tag.md" start="" end="" %} From b22ce44c11696dff9e286514af3f86057b60ba7e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 21 Jun 2024 20:52:56 -0700 Subject: [PATCH 24/28] self-review --- CHANGELOG.md | 4 +++- docs/src/reference/router.md | 2 +- docs/src/reference/template-tag.md | 8 +++++--- src/reactpy_django/pyscript/component_template.py | 4 ++-- src/reactpy_django/pyscript/layout_handler.py | 9 +++++---- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b08214..fdb3cc59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,9 @@ Using the following categories, list your changes in this order: ### Added -- Client-side Python components can now be rendered via the new `pyscript_component` template tag +- Client-side Python components can now be rendered via the new `{% pyscript_component %}` template tag +- PyScript's can be made accessible for an existing page using the `{% pyscript_setup %}` template tag + - This tag can also be used to load additional dependencies, or change the default PyScript configuration. - Client-side components can be embedded into existing server-side components via `reactpy_django.components.pyscript_component`. - You can now write Python code that runs within client browser via the `reactpy_django.html.pyscript` element. This is a viable substitution for most JavaScript code. diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 29bbcf15..5efe34a0 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -20,7 +20,7 @@ URL router that enables the ability to conditionally render other components bas !!! warning "Pitfall" - All pages where this component exists must have the same, or more permissive exposure within Django's [URL patterns](https://docs.djangoproject.com/en/5.0/topics/http/urls/#example). You can think of this component as a secondary, client-side router. Django still handles the primary server-side routes. + All pages where `django_router` is used must have the same, or more permissive URL exposure within Django's [URL patterns](https://docs.djangoproject.com/en/5.0/topics/http/urls/#example). You can think of this component as a secondary, client-side router. Django still handles the primary server-side routes. We recommend creating a route with a wildcard `.*` to forward routes to ReactPy. For example... `#!python re_path(r"^/router/.*$", my_reactpy_view)` diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index f5cf8bf7..49c6320f 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -39,7 +39,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c The ReactPy component finder requires that your component path is a string. - **Do not** use Django template/context variables for the component path. Failure to follow this warning will result in components that will not render. + **Do not** use Django template/context variables for the component path. Failure to follow this warning will result in render failures. For example, **do not** do the following: @@ -157,7 +157,7 @@ This template tag can be used to insert any number of **client-side** ReactPy co -By default, the only dependencies available are the standard Python library, `pyscript`, `pyodide`, `reactpy` core. +By default, the only dependencies available are the Python standard library, `pyscript`, `pyodide`, `reactpy` core. Your PyScript component file requires a `#!python def root()` component to function as the entry point. @@ -197,6 +197,8 @@ Your PyScript component file requires a `#!python def root()` component to funct PyScript components have the ability to directly execute JavaScript using the [`pyodide` `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) or [`pyscript` foreign function interface](https://docs.pyscript.net/2024.6.1/user-guide/dom/). + _The `#!python js` module has access to everything within the browser's JavaScript environment. Therefore, any public JavaScript functions loaded within your HTML `#!html ` can be called as well. However, be mindful of JavaScript load order!_ + === "root.py" ```python @@ -279,7 +281,7 @@ Your PyScript component file requires a `#!python def root()` component to funct ## PyScript Setup -This template tag configures the current page to be able to run `pyscript` by loading JavaScript, CSS, and settings values. +This template tag configures the current page to be able to run `pyscript` by loading PyScript's static files. You can optionally include a list of Python packages to install within the PyScript environment, or a [PyScript configuration dictionary](https://docs.pyscript.net/2024.6.1/user-guide/configuration/). diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index 0ebfe706..e2288ccf 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -12,9 +12,9 @@ def user_workspace_UUID(): to prevent overlapping imports and variable names between different components. This code is designed to be run directly by PyScript, and is not intended to be run - in a standard Python environment. + in a normal Python environment. - Our template tag performs string substitutions to turn this file into valid PyScript. + ReactPy-Django's template tag performs string substitutions to turn this file into valid PyScript. """ def root(): ... diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index bc2b7224..04e8a6e6 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -7,7 +7,7 @@ class ReactPyLayoutHandler: variable names between user code. This code is designed to be run directly by PyScript, and is not intended to be run - in a standard Python environment. + in a normal Python environment. """ def __init__(self, uuid): @@ -87,9 +87,10 @@ def event_handler(*args): @staticmethod def delete_old_workspaces(): - """To prevent memory leaks, we must delete all user generated Python code - whe it is no longer on the page. To do this, we compare what UUIDs exist on - the DOM, versus what UUIDs exist within the PyScript global interpreter.""" + """To prevent memory leaks, we must delete all user generated Python code when + it is no longer in use (removed from the page). To do this, we compare what + UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global + interpreter.""" import js dom_workspaces = js.document.querySelectorAll(".pyscript") From 825fde6732ba126c74b35152548ebb114572bd71 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jun 2024 01:22:50 -0700 Subject: [PATCH 25/28] Use morphdom to modify the DOM --- .gitignore | 1 + setup.py | 8 ++++++ src/js/package-lock.json | 6 +++++ src/js/package.json | 1 + src/reactpy_django/pyscript/layout_handler.py | 13 +++++++--- .../templates/reactpy/pyscript_setup.html | 2 +- src/reactpy_django/utils.py | 25 +++++++++++++------ tests/test_app/__init__.py | 17 ++++++++++++- 8 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index e8b35e23..ffabb7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # ReactPy-Django Build Artifacts src/reactpy_django/static/reactpy_django/client.js src/reactpy_django/static/reactpy_django/pyscript +src/reactpy_django/static/reactpy_django/morphdom # Django # logs diff --git a/setup.py b/setup.py index 056788f6..76a91edf 100644 --- a/setup.py +++ b/setup.py @@ -123,6 +123,14 @@ def run(self): for file in pyscript_dist.iterdir(): shutil.copy(file, pyscript_static_dir / file.name) + log.info("Copying Morphdom distribution") + morphdom_dist = js_dir / "node_modules" / "morphdom" / "dist" + morphdom_static_dir = static_dir / "morphdom" + if not morphdom_static_dir.exists(): + morphdom_static_dir.mkdir() + for file in morphdom_dist.iterdir(): + shutil.copy(file, morphdom_static_dir / file.name) + log.info("Successfully built Javascript") super().run() diff --git a/src/js/package-lock.json b/src/js/package-lock.json index c93c0882..d4cb1c0b 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -8,6 +8,7 @@ "@pyscript/core": "^0.4.48", "@reactpy/client": "^0.3.1", "@rollup/plugin-typescript": "^11.1.6", + "morphdom": "^2.7.3", "tslib": "^2.6.2" }, "devDependencies": { @@ -2277,6 +2278,11 @@ "node": ">=10" } }, + "node_modules/morphdom": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.3.tgz", + "integrity": "sha512-rvGK92GxSuPEZLY8D/JH07cG3BxyA+/F0Bxg32OoGAEFFhGWA3OqVpqPZlOgZTCR52clXrmz+z2pYSJ6gOig1w==" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/src/js/package.json b/src/js/package.json index f23efc5d..949b6cf9 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -23,6 +23,7 @@ "@pyscript/core": "^0.4.48", "@reactpy/client": "^0.3.1", "@rollup/plugin-typescript": "^11.1.6", + "morphdom": "^2.7.3", "tslib": "^2.6.2" } } diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 04e8a6e6..da5bfb1b 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -26,13 +26,18 @@ def apply_update(update, root_model): def render(self, layout, model): """Submit ReactPy's internal DOM model into the HTML DOM.""" import js + from pyscript.js_modules import morphdom + # Create a new container to render the layout into container = js.document.getElementById(f"pyscript-{self.uuid}") + temp_container = container.cloneNode(False) + self.build_element_tree(layout, temp_container, model) - # FIXME: The current implementation completely recreates the DOM on every render. - # This is not ideal, and should be optimized in the future. - container.innerHTML = "" - self.build_element_tree(layout, container, model) + # Use morphdom to update the DOM + morphdom.default(container, temp_container) + + # Remove the cloned container to prevent memory leaks + temp_container.remove() def build_element_tree(self, layout, parent, model): """Recursively build an element tree, starting from the root component.""" diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index 0b07fae3..e258cf08 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -4,5 +4,5 @@ {% if not reactpy_debug_mode %} {% endif %} - + {{pyscript_layout_handler}} diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index e1416c9a..55b52b41 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -25,6 +25,7 @@ from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse from django.template import engines +from django.templatetags.static import static from django.utils.encoding import smart_str from django.views import View from reactpy import vdom_to_html @@ -62,13 +63,7 @@ PYSCRIPT_LAYOUT_HANDLER = ( Path(__file__).parent / "pyscript" / "layout_handler.py" ).read_text(encoding="utf-8") -PYSCRIPT_DEFAULT_CONFIG = { - "packages": [ - f"reactpy=={reactpy.__version__}", - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - ] -} +PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {} async def render_view( @@ -503,6 +498,22 @@ def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: """Extends ReactPy's default PyScript config with user provided values.""" + if not PYSCRIPT_DEFAULT_CONFIG: + # Need to perform this lazily in order to wait for static files to be available + PYSCRIPT_DEFAULT_CONFIG.update( + { + "packages": [ + f"reactpy=={reactpy.__version__}", + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ], + "js_modules": { + "main": { + static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom" + } + }, + } + ) pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) pyscript_config["packages"].extend(extra_packages) if config and isinstance(config, str): diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 7c4a70c6..392e6066 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -8,7 +8,7 @@ assert npm.call(["install"], cwd=str(js_dir)) == 0 assert npm.call(["run", "build"], cwd=str(js_dir)) == 0 -# Make sure the the PyScript distribution is always available +# Make sure the current PyScript distribution is always available pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" pyscript_static_dir = ( Path(__file__).parent.parent.parent @@ -22,3 +22,18 @@ pyscript_static_dir.mkdir() for file in pyscript_dist.iterdir(): shutil.copy(file, pyscript_static_dir / file.name) + +# Make sure the current Morphdom distrubiton is always available +morphdom_dist = js_dir / "node_modules" / "morphdom" / "dist" +morphdom_static_dir = ( + Path(__file__).parent.parent.parent + / "src" + / "reactpy_django" + / "static" + / "reactpy_django" + / "morphdom" +) +if not morphdom_static_dir.exists(): + morphdom_static_dir.mkdir() +for file in morphdom_dist.iterdir(): + shutil.copy(file, morphdom_static_dir / file.name) From dd7179cacffa2d7f449f2d4f6bb70b10bed4ca55 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jun 2024 03:16:54 -0700 Subject: [PATCH 26/28] Allow defining custom JS modules --- docs/examples/html/pyscript-js-module.html | 14 + .../html/pyscript-setup-config-string.html | 2 +- .../html/pyscript-setup-extra-js-object.html | 14 + .../html/pyscript-setup-extra-js-string.html | 14 + docs/examples/python/pyscript-js-module.py | 12 + .../python/pyscript-setup-extra-js-object.py | 10 + docs/src/reference/template-tag.md | 49 +- src/reactpy_django/templatetags/reactpy.py | 10 +- src/reactpy_django/utils.py | 23 +- .../pyscript/components/remote_js_module.py | 14 + tests/test_app/static/moment.js | 5680 +++++++++++++++++ tests/test_app/templates/pyscript.html | 4 +- tests/test_app/tests/test_components.py | 1 + 13 files changed, 5832 insertions(+), 15 deletions(-) create mode 100644 docs/examples/html/pyscript-js-module.html create mode 100644 docs/examples/html/pyscript-setup-extra-js-object.html create mode 100644 docs/examples/html/pyscript-setup-extra-js-string.html create mode 100644 docs/examples/python/pyscript-js-module.py create mode 100644 docs/examples/python/pyscript-setup-extra-js-object.py create mode 100644 tests/test_app/pyscript/components/remote_js_module.py create mode 100644 tests/test_app/static/moment.js diff --git a/docs/examples/html/pyscript-js-module.html b/docs/examples/html/pyscript-js-module.html new file mode 100644 index 00000000..2d0130fb --- /dev/null +++ b/docs/examples/html/pyscript-js-module.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup extra_js='{"/static/moment.js":"moment"}' %} + + + + {% component "example_project.my_app.components.root.py" %} + + + diff --git a/docs/examples/html/pyscript-setup-config-string.html b/docs/examples/html/pyscript-setup-config-string.html index d89e1ad7..842bb769 100644 --- a/docs/examples/html/pyscript-setup-config-string.html +++ b/docs/examples/html/pyscript-setup-config-string.html @@ -1,4 +1,4 @@ ReactPy - {% pyscript_setup config="{'experimental_create_proxy':'auto'}" %} + {% pyscript_setup config='{"experimental_create_proxy":"auto"}' %} diff --git a/docs/examples/html/pyscript-setup-extra-js-object.html b/docs/examples/html/pyscript-setup-extra-js-object.html new file mode 100644 index 00000000..815cb040 --- /dev/null +++ b/docs/examples/html/pyscript-setup-extra-js-object.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup extra_js=my_extra_js_object %} + + + + {% component "example_project.my_app.components.root.py" %} + + + diff --git a/docs/examples/html/pyscript-setup-extra-js-string.html b/docs/examples/html/pyscript-setup-extra-js-string.html new file mode 100644 index 00000000..2d0130fb --- /dev/null +++ b/docs/examples/html/pyscript-setup-extra-js-string.html @@ -0,0 +1,14 @@ +{% load reactpy %} + + + + + ReactPy + {% pyscript_setup extra_js='{"/static/moment.js":"moment"}' %} + + + + {% component "example_project.my_app.components.root.py" %} + + + diff --git a/docs/examples/python/pyscript-js-module.py b/docs/examples/python/pyscript-js-module.py new file mode 100644 index 00000000..221b5bae --- /dev/null +++ b/docs/examples/python/pyscript-js-module.py @@ -0,0 +1,12 @@ +from reactpy import component, html + + +@component +def root(): + from pyscript.js_modules import moment + + return html.div( + {"id": "moment"}, + "Using the JavaScript package 'moment' to calculate time: ", + moment.default().format("YYYY-MM-DD HH:mm:ss"), + ) diff --git a/docs/examples/python/pyscript-setup-extra-js-object.py b/docs/examples/python/pyscript-setup-extra-js-object.py new file mode 100644 index 00000000..805365cf --- /dev/null +++ b/docs/examples/python/pyscript-setup-extra-js-object.py @@ -0,0 +1,10 @@ +from django.shortcuts import render +from django.templatetags.static import static + + +def index(request): + return render( + request, + "my_template.html", + context={"my_extra_js_object": {static("moment.js"): "moment"}}, + ) diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 49c6320f..b6070fd8 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -195,9 +195,9 @@ Your PyScript component file requires a `#!python def root()` component to funct ??? question "How do I execute JavaScript within PyScript components?" - PyScript components have the ability to directly execute JavaScript using the [`pyodide` `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) or [`pyscript` foreign function interface](https://docs.pyscript.net/2024.6.1/user-guide/dom/). + PyScript components have the ability to directly execute standard library JavaScript using the [`pyodide` `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) or [`pyscript` foreign function interface](https://docs.pyscript.net/2024.6.1/user-guide/dom/). - _The `#!python js` module has access to everything within the browser's JavaScript environment. Therefore, any public JavaScript functions loaded within your HTML `#!html ` can be called as well. However, be mindful of JavaScript load order!_ + The `#!python js` module has access to everything within the browser's JavaScript environment. Therefore, any global JavaScript functions loaded within your HTML `#!html ` can be called as well. However, be mindful of JavaScript load order! === "root.py" @@ -205,6 +205,20 @@ Your PyScript component file requires a `#!python def root()` component to funct {% include "../../examples/python/pyscript-js-execution.py" %} ``` + To import JavaScript modules in a fashion similar to `#!javascript import {moment} from 'static/moment.js'`, you will need to configure your `#!jinja {% pyscript_setup %}` block to make the module available to PyScript. This module will be accessed within `#!python pyscript.js_modules.*`. For more information, see the [PyScript JS modules docs](https://docs.pyscript.net/2024.6.2/user-guide/configuration/#javascript-modules). + + === "root.py" + + ```python + {% include "../../examples/python/pyscript-js-module.py" %} + ``` + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-js-module.html" %} + ``` + @@ -297,12 +311,13 @@ You can optionally include a list of Python packages to install within the PyScr | Name | Type | Description | Default | | --- | --- | --- | --- | - | `#!python *dependencies` | `#!python str` | Dependencies that need to be loaded on the page for your PyScript components. Each dependency must be contained within it's own string and written in Python requirements file syntax. | N/A | + | `#!python *extra_py` | `#!python str` | Dependencies that need to be loaded on the page for your PyScript components. Each dependency must be contained within it's own string and written in Python requirements file syntax. | N/A | + | `#!python extra_js` | `#!python str | dict` | A JSON string or Python dictionary containing a vanilla JavaScript module URL and the `#!python name: str` to access it within `#!python pyscript.js_modules.*`. | `#!python ""` | | `#!python config` | `#!python str | dict` | A JSON string or Python dictionary containing PyScript configuration values. | `#!python ""` | -??? question "How do I define dependencies for my PyScript component?" +??? question "How do I install additional Python dependencies?" - Dependencies must be available on [`pypi`](https://pypi.org/), written in pure Python, and declared in your `#!jinja {% pyscript_setup %}` block using Python requirements file syntax. + Dependencies must be available on [`pypi`](https://pypi.org/) and declared in your `#!jinja {% pyscript_setup %}` block using Python requirements file syntax. These dependencies are automatically downloaded and installed into the PyScript client-side environment when the page is loaded. @@ -312,6 +327,30 @@ You can optionally include a list of Python packages to install within the PyScr {% include "../../examples/html/pyscript-setup-dependencies.html" %} ``` +??? question "How do I install additional Javascript dependencies?" + + You can use the `#!python extra_js` keyword to load additional JavaScript modules into your PyScript environment. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup-extra-js-object.html" %} + ``` + + === "views.py" + + ```python + {% include "../../examples/python/pyscript-setup-extra-js-object.py" %} + ``` + + The value for `#!python extra_js` is most commonly a Python dictionary, but JSON strings are also supported. + + === "my_template.html" + + ```jinja + {% include "../../examples/html/pyscript-setup-extra-js-string.html" %} + ``` + ??? question "How do I modify the `pyscript` default configuration?" You can modify the default [PyScript configuration](https://docs.pyscript.net/2024.6.2/user-guide/configuration/) by providing a value to the `#!python config` keyword. diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 4e808ecf..1fdfa6af 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -217,21 +217,25 @@ def pyscript_component( @register.inclusion_tag("reactpy/pyscript_setup.html") def pyscript_setup( - *dependencies: str, + *extra_py: str, + extra_js: str | dict = "", config: str | dict = "", ): """ Args: - dependencies: Dependencies that need to be loaded on the page for \ + extra_py: Dependencies that need to be loaded on the page for \ your PyScript components. Each dependency must be contained \ within it's own string and written in Python requirements file syntax. Kwargs: + extra_js: A JSON string or Python dictionary containing a vanilla \ + JavaScript module URL and the `name: str` to access it within \ + `pyscript.js_modules.*`. config: A JSON string or Python dictionary containing PyScript \ configuration values. """ return { - "pyscript_config": extend_pyscript_config(config, dependencies), + "pyscript_config": extend_pyscript_config(extra_py, extra_js, config), "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 55b52b41..48559e84 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -2,6 +2,7 @@ import contextlib import inspect +import json import logging import os import re @@ -496,10 +497,12 @@ def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): return executor.replace(" def root(): ...", user_code) -def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: +def extend_pyscript_config( + extra_py: Sequence, extra_js: dict | str, config: dict | str +) -> str: """Extends ReactPy's default PyScript config with user provided values.""" + # Lazily set up the initial config in to wait for Django's static file system if not PYSCRIPT_DEFAULT_CONFIG: - # Need to perform this lazily in order to wait for static files to be available PYSCRIPT_DEFAULT_CONFIG.update( { "packages": [ @@ -514,11 +517,21 @@ def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> str: }, } ) + + # Extend the Python dependency list pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) - pyscript_config["packages"].extend(extra_packages) + pyscript_config["packages"].extend(extra_py) + + # Extend the JavaScript dependency list + if extra_js and isinstance(extra_js, str): + pyscript_config["js_modules"]["main"].update(json.loads(extra_js)) + elif extra_js and isinstance(extra_js, dict): + pyscript_config["js_modules"]["main"].update(extra_py) + + # Update the config if config and isinstance(config, str): - pyscript_config.update(orjson.loads(config)) - elif isinstance(config, dict): + pyscript_config.update(json.loads(config)) + elif config and isinstance(config, dict): pyscript_config.update(config) return orjson.dumps(pyscript_config).decode("utf-8") diff --git a/tests/test_app/pyscript/components/remote_js_module.py b/tests/test_app/pyscript/components/remote_js_module.py new file mode 100644 index 00000000..26eccf03 --- /dev/null +++ b/tests/test_app/pyscript/components/remote_js_module.py @@ -0,0 +1,14 @@ +from reactpy import component, html + + +@component +def root(): + from pyscript.js_modules import moment + + time: str = moment.default().format("YYYY-MM-DD HH:mm:ss") + + return html.div( + {"id": "moment", "data-success": bool(time)}, + "Using the JavaScript package 'moment' to calculate time: ", + time, + ) diff --git a/tests/test_app/static/moment.js b/tests/test_app/static/moment.js new file mode 100644 index 00000000..956eeb3d --- /dev/null +++ b/tests/test_app/static/moment.js @@ -0,0 +1,5680 @@ +//! moment.js +//! version : 2.30.1 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com + +var hookCallback; + +function hooks() { + return hookCallback.apply(null, arguments); +} + +// This is done to register the method called with moment() +// without creating circular dependencies. +function setHookCallback(callback) { + hookCallback = callback; +} + +function isArray(input) { + return ( + input instanceof Array || + Object.prototype.toString.call(input) === '[object Array]' + ); +} + +function isObject(input) { + // IE8 will treat undefined and null as object if it wasn't for + // input != null + return ( + input != null && + Object.prototype.toString.call(input) === '[object Object]' + ); +} + +function hasOwnProp(a, b) { + return Object.prototype.hasOwnProperty.call(a, b); +} + +function isObjectEmpty(obj) { + if (Object.getOwnPropertyNames) { + return Object.getOwnPropertyNames(obj).length === 0; + } else { + var k; + for (k in obj) { + if (hasOwnProp(obj, k)) { + return false; + } + } + return true; + } +} + +function isUndefined(input) { + return input === void 0; +} + +function isNumber(input) { + return ( + typeof input === 'number' || + Object.prototype.toString.call(input) === '[object Number]' + ); +} + +function isDate(input) { + return ( + input instanceof Date || + Object.prototype.toString.call(input) === '[object Date]' + ); +} + +function map(arr, fn) { + var res = [], + i, + arrLen = arr.length; + for (i = 0; i < arrLen; ++i) { + res.push(fn(arr[i], i)); + } + return res; +} + +function extend(a, b) { + for (var i in b) { + if (hasOwnProp(b, i)) { + a[i] = b[i]; + } + } + + if (hasOwnProp(b, 'toString')) { + a.toString = b.toString; + } + + if (hasOwnProp(b, 'valueOf')) { + a.valueOf = b.valueOf; + } + + return a; +} + +function createUTC(input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, true).utc(); +} + +function defaultParsingFlags() { + // We need to deep clone this object. + return { + empty: false, + unusedTokens: [], + unusedInput: [], + overflow: -2, + charsLeftOver: 0, + nullInput: false, + invalidEra: null, + invalidMonth: null, + invalidFormat: false, + userInvalidated: false, + iso: false, + parsedDateParts: [], + era: null, + meridiem: null, + rfc2822: false, + weekdayMismatch: false, + }; +} + +function getParsingFlags(m) { + if (m._pf == null) { + m._pf = defaultParsingFlags(); + } + return m._pf; +} + +var some; +if (Array.prototype.some) { + some = Array.prototype.some; +} else { + some = function (fun) { + var t = Object(this), + len = t.length >>> 0, + i; + + for (i = 0; i < len; i++) { + if (i in t && fun.call(this, t[i], i, t)) { + return true; + } + } + + return false; + }; +} + +function isValid(m) { + var flags = null, + parsedParts = false, + isNowValid = m._d && !isNaN(m._d.getTime()); + if (isNowValid) { + flags = getParsingFlags(m); + parsedParts = some.call(flags.parsedDateParts, function (i) { + return i != null; + }); + isNowValid = + flags.overflow < 0 && + !flags.empty && + !flags.invalidEra && + !flags.invalidMonth && + !flags.invalidWeekday && + !flags.weekdayMismatch && + !flags.nullInput && + !flags.invalidFormat && + !flags.userInvalidated && + (!flags.meridiem || (flags.meridiem && parsedParts)); + if (m._strict) { + isNowValid = + isNowValid && + flags.charsLeftOver === 0 && + flags.unusedTokens.length === 0 && + flags.bigHour === undefined; + } + } + if (Object.isFrozen == null || !Object.isFrozen(m)) { + m._isValid = isNowValid; + } else { + return isNowValid; + } + return m._isValid; +} + +function createInvalid(flags) { + var m = createUTC(NaN); + if (flags != null) { + extend(getParsingFlags(m), flags); + } else { + getParsingFlags(m).userInvalidated = true; + } + + return m; +} + +// Plugins that add properties should also add the key here (null value), +// so we can properly clone ourselves. +var momentProperties = (hooks.momentProperties = []), + updateInProgress = false; + +function copyConfig(to, from) { + var i, + prop, + val, + momentPropertiesLen = momentProperties.length; + + if (!isUndefined(from._isAMomentObject)) { + to._isAMomentObject = from._isAMomentObject; + } + if (!isUndefined(from._i)) { + to._i = from._i; + } + if (!isUndefined(from._f)) { + to._f = from._f; + } + if (!isUndefined(from._l)) { + to._l = from._l; + } + if (!isUndefined(from._strict)) { + to._strict = from._strict; + } + if (!isUndefined(from._tzm)) { + to._tzm = from._tzm; + } + if (!isUndefined(from._isUTC)) { + to._isUTC = from._isUTC; + } + if (!isUndefined(from._offset)) { + to._offset = from._offset; + } + if (!isUndefined(from._pf)) { + to._pf = getParsingFlags(from); + } + if (!isUndefined(from._locale)) { + to._locale = from._locale; + } + + if (momentPropertiesLen > 0) { + for (i = 0; i < momentPropertiesLen; i++) { + prop = momentProperties[i]; + val = from[prop]; + if (!isUndefined(val)) { + to[prop] = val; + } + } + } + + return to; +} + +// Moment prototype object +function Moment(config) { + copyConfig(this, config); + this._d = new Date(config._d != null ? config._d.getTime() : NaN); + if (!this.isValid()) { + this._d = new Date(NaN); + } + // Prevent infinite loop in case updateOffset creates new moment + // objects. + if (updateInProgress === false) { + updateInProgress = true; + hooks.updateOffset(this); + updateInProgress = false; + } +} + +function isMoment(obj) { + return ( + obj instanceof Moment || (obj != null && obj._isAMomentObject != null) + ); +} + +function warn(msg) { + if ( + hooks.suppressDeprecationWarnings === false && + typeof console !== 'undefined' && + console.warn + ) { + console.warn('Deprecation warning: ' + msg); + } +} + +function deprecate(msg, fn) { + var firstTime = true; + + return extend(function () { + if (hooks.deprecationHandler != null) { + hooks.deprecationHandler(null, msg); + } + if (firstTime) { + var args = [], + arg, + i, + key, + argLen = arguments.length; + for (i = 0; i < argLen; i++) { + arg = ''; + if (typeof arguments[i] === 'object') { + arg += '\n[' + i + '] '; + for (key in arguments[0]) { + if (hasOwnProp(arguments[0], key)) { + arg += key + ': ' + arguments[0][key] + ', '; + } + } + arg = arg.slice(0, -2); // Remove trailing comma and space + } else { + arg = arguments[i]; + } + args.push(arg); + } + warn( + msg + + '\nArguments: ' + + Array.prototype.slice.call(args).join('') + + '\n' + + new Error().stack + ); + firstTime = false; + } + return fn.apply(this, arguments); + }, fn); +} + +var deprecations = {}; + +function deprecateSimple(name, msg) { + if (hooks.deprecationHandler != null) { + hooks.deprecationHandler(name, msg); + } + if (!deprecations[name]) { + warn(msg); + deprecations[name] = true; + } +} + +hooks.suppressDeprecationWarnings = false; +hooks.deprecationHandler = null; + +function isFunction(input) { + return ( + (typeof Function !== 'undefined' && input instanceof Function) || + Object.prototype.toString.call(input) === '[object Function]' + ); +} + +function set(config) { + var prop, i; + for (i in config) { + if (hasOwnProp(config, i)) { + prop = config[i]; + if (isFunction(prop)) { + this[i] = prop; + } else { + this['_' + i] = prop; + } + } + } + this._config = config; + // Lenient ordinal parsing accepts just a number in addition to + // number + (possibly) stuff coming from _dayOfMonthOrdinalParse. + // TODO: Remove "ordinalParse" fallback in next major release. + this._dayOfMonthOrdinalParseLenient = new RegExp( + (this._dayOfMonthOrdinalParse.source || this._ordinalParse.source) + + '|' + + /\d{1,2}/.source + ); +} + +function mergeConfigs(parentConfig, childConfig) { + var res = extend({}, parentConfig), + prop; + for (prop in childConfig) { + if (hasOwnProp(childConfig, prop)) { + if (isObject(parentConfig[prop]) && isObject(childConfig[prop])) { + res[prop] = {}; + extend(res[prop], parentConfig[prop]); + extend(res[prop], childConfig[prop]); + } else if (childConfig[prop] != null) { + res[prop] = childConfig[prop]; + } else { + delete res[prop]; + } + } + } + for (prop in parentConfig) { + if ( + hasOwnProp(parentConfig, prop) && + !hasOwnProp(childConfig, prop) && + isObject(parentConfig[prop]) + ) { + // make sure changes to properties don't modify parent config + res[prop] = extend({}, res[prop]); + } + } + return res; +} + +function Locale(config) { + if (config != null) { + this.set(config); + } +} + +var keys; + +if (Object.keys) { + keys = Object.keys; +} else { + keys = function (obj) { + var i, + res = []; + for (i in obj) { + if (hasOwnProp(obj, i)) { + res.push(i); + } + } + return res; + }; +} + +var defaultCalendar = { + sameDay: '[Today at] LT', + nextDay: '[Tomorrow at] LT', + nextWeek: 'dddd [at] LT', + lastDay: '[Yesterday at] LT', + lastWeek: '[Last] dddd [at] LT', + sameElse: 'L', +}; + +function calendar(key, mom, now) { + var output = this._calendar[key] || this._calendar['sameElse']; + return isFunction(output) ? output.call(mom, now) : output; +} + +function zeroFill(number, targetLength, forceSign) { + var absNumber = '' + Math.abs(number), + zerosToFill = targetLength - absNumber.length, + sign = number >= 0; + return ( + (sign ? (forceSign ? '+' : '') : '-') + + Math.pow(10, Math.max(0, zerosToFill)).toString().substr(1) + + absNumber + ); +} + +var formattingTokens = + /(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g, + localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, + formatFunctions = {}, + formatTokenFunctions = {}; + +// token: 'M' +// padded: ['MM', 2] +// ordinal: 'Mo' +// callback: function () { this.month() + 1 } +function addFormatToken(token, padded, ordinal, callback) { + var func = callback; + if (typeof callback === 'string') { + func = function () { + return this[callback](); + }; + } + if (token) { + formatTokenFunctions[token] = func; + } + if (padded) { + formatTokenFunctions[padded[0]] = function () { + return zeroFill(func.apply(this, arguments), padded[1], padded[2]); + }; + } + if (ordinal) { + formatTokenFunctions[ordinal] = function () { + return this.localeData().ordinal( + func.apply(this, arguments), + token + ); + }; + } +} + +function removeFormattingTokens(input) { + if (input.match(/\[[\s\S]/)) { + return input.replace(/^\[|\]$/g, ''); + } + return input.replace(/\\/g, ''); +} + +function makeFormatFunction(format) { + var array = format.match(formattingTokens), + i, + length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = '', + i; + for (i = 0; i < length; i++) { + output += isFunction(array[i]) + ? array[i].call(mom, format) + : array[i]; + } + return output; + }; +} + +// format date using native date object +function formatMoment(m, format) { + if (!m.isValid()) { + return m.localeData().invalidDate(); + } + + format = expandFormat(format, m.localeData()); + formatFunctions[format] = + formatFunctions[format] || makeFormatFunction(format); + + return formatFunctions[format](m); +} + +function expandFormat(format, locale) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return locale.longDateFormat(input) || input; + } + + localFormattingTokens.lastIndex = 0; + while (i >= 0 && localFormattingTokens.test(format)) { + format = format.replace( + localFormattingTokens, + replaceLongDateFormatTokens + ); + localFormattingTokens.lastIndex = 0; + i -= 1; + } + + return format; +} + +var defaultLongDateFormat = { + LTS: 'h:mm:ss A', + LT: 'h:mm A', + L: 'MM/DD/YYYY', + LL: 'MMMM D, YYYY', + LLL: 'MMMM D, YYYY h:mm A', + LLLL: 'dddd, MMMM D, YYYY h:mm A', +}; + +function longDateFormat(key) { + var format = this._longDateFormat[key], + formatUpper = this._longDateFormat[key.toUpperCase()]; + + if (format || !formatUpper) { + return format; + } + + this._longDateFormat[key] = formatUpper + .match(formattingTokens) + .map(function (tok) { + if ( + tok === 'MMMM' || + tok === 'MM' || + tok === 'DD' || + tok === 'dddd' + ) { + return tok.slice(1); + } + return tok; + }) + .join(''); + + return this._longDateFormat[key]; +} + +var defaultInvalidDate = 'Invalid date'; + +function invalidDate() { + return this._invalidDate; +} + +var defaultOrdinal = '%d', + defaultDayOfMonthOrdinalParse = /\d{1,2}/; + +function ordinal(number) { + return this._ordinal.replace('%d', number); +} + +var defaultRelativeTime = { + future: 'in %s', + past: '%s ago', + s: 'a few seconds', + ss: '%d seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + w: 'a week', + ww: '%d weeks', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', +}; + +function relativeTime(number, withoutSuffix, string, isFuture) { + var output = this._relativeTime[string]; + return isFunction(output) + ? output(number, withoutSuffix, string, isFuture) + : output.replace(/%d/i, number); +} + +function pastFuture(diff, output) { + var format = this._relativeTime[diff > 0 ? 'future' : 'past']; + return isFunction(format) ? format(output) : format.replace(/%s/i, output); +} + +var aliases = { + D: 'date', + dates: 'date', + date: 'date', + d: 'day', + days: 'day', + day: 'day', + e: 'weekday', + weekdays: 'weekday', + weekday: 'weekday', + E: 'isoWeekday', + isoweekdays: 'isoWeekday', + isoweekday: 'isoWeekday', + DDD: 'dayOfYear', + dayofyears: 'dayOfYear', + dayofyear: 'dayOfYear', + h: 'hour', + hours: 'hour', + hour: 'hour', + ms: 'millisecond', + milliseconds: 'millisecond', + millisecond: 'millisecond', + m: 'minute', + minutes: 'minute', + minute: 'minute', + M: 'month', + months: 'month', + month: 'month', + Q: 'quarter', + quarters: 'quarter', + quarter: 'quarter', + s: 'second', + seconds: 'second', + second: 'second', + gg: 'weekYear', + weekyears: 'weekYear', + weekyear: 'weekYear', + GG: 'isoWeekYear', + isoweekyears: 'isoWeekYear', + isoweekyear: 'isoWeekYear', + w: 'week', + weeks: 'week', + week: 'week', + W: 'isoWeek', + isoweeks: 'isoWeek', + isoweek: 'isoWeek', + y: 'year', + years: 'year', + year: 'year', +}; + +function normalizeUnits(units) { + return typeof units === 'string' + ? aliases[units] || aliases[units.toLowerCase()] + : undefined; +} + +function normalizeObjectUnits(inputObject) { + var normalizedInput = {}, + normalizedProp, + prop; + + for (prop in inputObject) { + if (hasOwnProp(inputObject, prop)) { + normalizedProp = normalizeUnits(prop); + if (normalizedProp) { + normalizedInput[normalizedProp] = inputObject[prop]; + } + } + } + + return normalizedInput; +} + +var priorities = { + date: 9, + day: 11, + weekday: 11, + isoWeekday: 11, + dayOfYear: 4, + hour: 13, + millisecond: 16, + minute: 14, + month: 8, + quarter: 7, + second: 15, + weekYear: 1, + isoWeekYear: 1, + week: 5, + isoWeek: 5, + year: 1, +}; + +function getPrioritizedUnits(unitsObj) { + var units = [], + u; + for (u in unitsObj) { + if (hasOwnProp(unitsObj, u)) { + units.push({ unit: u, priority: priorities[u] }); + } + } + units.sort(function (a, b) { + return a.priority - b.priority; + }); + return units; +} + +var match1 = /\d/, // 0 - 9 + match2 = /\d\d/, // 00 - 99 + match3 = /\d{3}/, // 000 - 999 + match4 = /\d{4}/, // 0000 - 9999 + match6 = /[+-]?\d{6}/, // -999999 - 999999 + match1to2 = /\d\d?/, // 0 - 99 + match3to4 = /\d\d\d\d?/, // 999 - 9999 + match5to6 = /\d\d\d\d\d\d?/, // 99999 - 999999 + match1to3 = /\d{1,3}/, // 0 - 999 + match1to4 = /\d{1,4}/, // 0 - 9999 + match1to6 = /[+-]?\d{1,6}/, // -999999 - 999999 + matchUnsigned = /\d+/, // 0 - inf + matchSigned = /[+-]?\d+/, // -inf - inf + matchOffset = /Z|[+-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z + matchShortOffset = /Z|[+-]\d\d(?::?\d\d)?/gi, // +00 -00 +00:00 -00:00 +0000 -0000 or Z + matchTimestamp = /[+-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 + // any word (or two) characters or numbers including two/three word month in arabic. + // includes scottish gaelic two word and hyphenated months + matchWord = + /[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i, + match1to2NoLeadingZero = /^[1-9]\d?/, // 1-99 + match1to2HasZero = /^([1-9]\d|\d)/, // 0-99 + regexes; + +regexes = {}; + +function addRegexToken(token, regex, strictRegex) { + regexes[token] = isFunction(regex) + ? regex + : function (isStrict, localeData) { + return isStrict && strictRegex ? strictRegex : regex; + }; +} + +function getParseRegexForToken(token, config) { + if (!hasOwnProp(regexes, token)) { + return new RegExp(unescapeFormat(token)); + } + + return regexes[token](config._strict, config._locale); +} + +// Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript +function unescapeFormat(s) { + return regexEscape( + s + .replace('\\', '') + .replace( + /\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, + function (matched, p1, p2, p3, p4) { + return p1 || p2 || p3 || p4; + } + ) + ); +} + +function regexEscape(s) { + return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +function absFloor(number) { + if (number < 0) { + // -0 -> 0 + return Math.ceil(number) || 0; + } else { + return Math.floor(number); + } +} + +function toInt(argumentForCoercion) { + var coercedNumber = +argumentForCoercion, + value = 0; + + if (coercedNumber !== 0 && isFinite(coercedNumber)) { + value = absFloor(coercedNumber); + } + + return value; +} + +var tokens = {}; + +function addParseToken(token, callback) { + var i, + func = callback, + tokenLen; + if (typeof token === 'string') { + token = [token]; + } + if (isNumber(callback)) { + func = function (input, array) { + array[callback] = toInt(input); + }; + } + tokenLen = token.length; + for (i = 0; i < tokenLen; i++) { + tokens[token[i]] = func; + } +} + +function addWeekParseToken(token, callback) { + addParseToken(token, function (input, array, config, token) { + config._w = config._w || {}; + callback(input, config._w, config, token); + }); +} + +function addTimeToArrayFromToken(token, input, config) { + if (input != null && hasOwnProp(tokens, token)) { + tokens[token](input, config._a, config, token); + } +} + +function isLeapYear(year) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +var YEAR = 0, + MONTH = 1, + DATE = 2, + HOUR = 3, + MINUTE = 4, + SECOND = 5, + MILLISECOND = 6, + WEEK = 7, + WEEKDAY = 8; + +// FORMATTING + +addFormatToken('Y', 0, 0, function () { + var y = this.year(); + return y <= 9999 ? zeroFill(y, 4) : '+' + y; +}); + +addFormatToken(0, ['YY', 2], 0, function () { + return this.year() % 100; +}); + +addFormatToken(0, ['YYYY', 4], 0, 'year'); +addFormatToken(0, ['YYYYY', 5], 0, 'year'); +addFormatToken(0, ['YYYYYY', 6, true], 0, 'year'); + +// PARSING + +addRegexToken('Y', matchSigned); +addRegexToken('YY', match1to2, match2); +addRegexToken('YYYY', match1to4, match4); +addRegexToken('YYYYY', match1to6, match6); +addRegexToken('YYYYYY', match1to6, match6); + +addParseToken(['YYYYY', 'YYYYYY'], YEAR); +addParseToken('YYYY', function (input, array) { + array[YEAR] = + input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input); +}); +addParseToken('YY', function (input, array) { + array[YEAR] = hooks.parseTwoDigitYear(input); +}); +addParseToken('Y', function (input, array) { + array[YEAR] = parseInt(input, 10); +}); + +// HELPERS + +function daysInYear(year) { + return isLeapYear(year) ? 366 : 365; +} + +// HOOKS + +hooks.parseTwoDigitYear = function (input) { + return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); +}; + +// MOMENTS + +var getSetYear = makeGetSet('FullYear', true); + +function getIsLeapYear() { + return isLeapYear(this.year()); +} + +function makeGetSet(unit, keepTime) { + return function (value) { + if (value != null) { + set$1(this, unit, value); + hooks.updateOffset(this, keepTime); + return this; + } else { + return get(this, unit); + } + }; +} + +function get(mom, unit) { + if (!mom.isValid()) { + return NaN; + } + + var d = mom._d, + isUTC = mom._isUTC; + + switch (unit) { + case 'Milliseconds': + return isUTC ? d.getUTCMilliseconds() : d.getMilliseconds(); + case 'Seconds': + return isUTC ? d.getUTCSeconds() : d.getSeconds(); + case 'Minutes': + return isUTC ? d.getUTCMinutes() : d.getMinutes(); + case 'Hours': + return isUTC ? d.getUTCHours() : d.getHours(); + case 'Date': + return isUTC ? d.getUTCDate() : d.getDate(); + case 'Day': + return isUTC ? d.getUTCDay() : d.getDay(); + case 'Month': + return isUTC ? d.getUTCMonth() : d.getMonth(); + case 'FullYear': + return isUTC ? d.getUTCFullYear() : d.getFullYear(); + default: + return NaN; // Just in case + } +} + +function set$1(mom, unit, value) { + var d, isUTC, year, month, date; + + if (!mom.isValid() || isNaN(value)) { + return; + } + + d = mom._d; + isUTC = mom._isUTC; + + switch (unit) { + case 'Milliseconds': + return void (isUTC + ? d.setUTCMilliseconds(value) + : d.setMilliseconds(value)); + case 'Seconds': + return void (isUTC ? d.setUTCSeconds(value) : d.setSeconds(value)); + case 'Minutes': + return void (isUTC ? d.setUTCMinutes(value) : d.setMinutes(value)); + case 'Hours': + return void (isUTC ? d.setUTCHours(value) : d.setHours(value)); + case 'Date': + return void (isUTC ? d.setUTCDate(value) : d.setDate(value)); + // case 'Day': // Not real + // return void (isUTC ? d.setUTCDay(value) : d.setDay(value)); + // case 'Month': // Not used because we need to pass two variables + // return void (isUTC ? d.setUTCMonth(value) : d.setMonth(value)); + case 'FullYear': + break; // See below ... + default: + return; // Just in case + } + + year = value; + month = mom.month(); + date = mom.date(); + date = date === 29 && month === 1 && !isLeapYear(year) ? 28 : date; + void (isUTC + ? d.setUTCFullYear(year, month, date) + : d.setFullYear(year, month, date)); +} + +// MOMENTS + +function stringGet(units) { + units = normalizeUnits(units); + if (isFunction(this[units])) { + return this[units](); + } + return this; +} + +function stringSet(units, value) { + if (typeof units === 'object') { + units = normalizeObjectUnits(units); + var prioritized = getPrioritizedUnits(units), + i, + prioritizedLen = prioritized.length; + for (i = 0; i < prioritizedLen; i++) { + this[prioritized[i].unit](units[prioritized[i].unit]); + } + } else { + units = normalizeUnits(units); + if (isFunction(this[units])) { + return this[units](value); + } + } + return this; +} + +function mod(n, x) { + return ((n % x) + x) % x; +} + +var indexOf; + +if (Array.prototype.indexOf) { + indexOf = Array.prototype.indexOf; +} else { + indexOf = function (o) { + // I know + var i; + for (i = 0; i < this.length; ++i) { + if (this[i] === o) { + return i; + } + } + return -1; + }; +} + +function daysInMonth(year, month) { + if (isNaN(year) || isNaN(month)) { + return NaN; + } + var modMonth = mod(month, 12); + year += (month - modMonth) / 12; + return modMonth === 1 + ? isLeapYear(year) + ? 29 + : 28 + : 31 - ((modMonth % 7) % 2); +} + +// FORMATTING + +addFormatToken('M', ['MM', 2], 'Mo', function () { + return this.month() + 1; +}); + +addFormatToken('MMM', 0, 0, function (format) { + return this.localeData().monthsShort(this, format); +}); + +addFormatToken('MMMM', 0, 0, function (format) { + return this.localeData().months(this, format); +}); + +// PARSING + +addRegexToken('M', match1to2, match1to2NoLeadingZero); +addRegexToken('MM', match1to2, match2); +addRegexToken('MMM', function (isStrict, locale) { + return locale.monthsShortRegex(isStrict); +}); +addRegexToken('MMMM', function (isStrict, locale) { + return locale.monthsRegex(isStrict); +}); + +addParseToken(['M', 'MM'], function (input, array) { + array[MONTH] = toInt(input) - 1; +}); + +addParseToken(['MMM', 'MMMM'], function (input, array, config, token) { + var month = config._locale.monthsParse(input, token, config._strict); + // if we didn't find a month name, mark the date as invalid. + if (month != null) { + array[MONTH] = month; + } else { + getParsingFlags(config).invalidMonth = input; + } +}); + +// LOCALES + +var defaultLocaleMonths = + 'January_February_March_April_May_June_July_August_September_October_November_December'.split( + '_' + ), + defaultLocaleMonthsShort = + 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), + MONTHS_IN_FORMAT = /D[oD]?(\[[^\[\]]*\]|\s)+MMMM?/, + defaultMonthsShortRegex = matchWord, + defaultMonthsRegex = matchWord; + +function localeMonths(m, format) { + if (!m) { + return isArray(this._months) + ? this._months + : this._months['standalone']; + } + return isArray(this._months) + ? this._months[m.month()] + : this._months[ + (this._months.isFormat || MONTHS_IN_FORMAT).test(format) + ? 'format' + : 'standalone' + ][m.month()]; +} + +function localeMonthsShort(m, format) { + if (!m) { + return isArray(this._monthsShort) + ? this._monthsShort + : this._monthsShort['standalone']; + } + return isArray(this._monthsShort) + ? this._monthsShort[m.month()] + : this._monthsShort[ + MONTHS_IN_FORMAT.test(format) ? 'format' : 'standalone' + ][m.month()]; +} + +function handleStrictParse(monthName, format, strict) { + var i, + ii, + mom, + llc = monthName.toLocaleLowerCase(); + if (!this._monthsParse) { + // this is not used + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + for (i = 0; i < 12; ++i) { + mom = createUTC([2000, i]); + this._shortMonthsParse[i] = this.monthsShort( + mom, + '' + ).toLocaleLowerCase(); + this._longMonthsParse[i] = this.months(mom, '').toLocaleLowerCase(); + } + } + + if (strict) { + if (format === 'MMM') { + ii = indexOf.call(this._shortMonthsParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._longMonthsParse, llc); + return ii !== -1 ? ii : null; + } + } else { + if (format === 'MMM') { + ii = indexOf.call(this._shortMonthsParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._longMonthsParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._longMonthsParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortMonthsParse, llc); + return ii !== -1 ? ii : null; + } + } +} + +function localeMonthsParse(monthName, format, strict) { + var i, mom, regex; + + if (this._monthsParseExact) { + return handleStrictParse.call(this, monthName, format, strict); + } + + if (!this._monthsParse) { + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + } + + // TODO: add sorting + // Sorting makes sure if one month (or abbr) is a prefix of another + // see sorting in computeMonthsParse + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, i]); + if (strict && !this._longMonthsParse[i]) { + this._longMonthsParse[i] = new RegExp( + '^' + this.months(mom, '').replace('.', '') + '$', + 'i' + ); + this._shortMonthsParse[i] = new RegExp( + '^' + this.monthsShort(mom, '').replace('.', '') + '$', + 'i' + ); + } + if (!strict && !this._monthsParse[i]) { + regex = + '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); + this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if ( + strict && + format === 'MMMM' && + this._longMonthsParse[i].test(monthName) + ) { + return i; + } else if ( + strict && + format === 'MMM' && + this._shortMonthsParse[i].test(monthName) + ) { + return i; + } else if (!strict && this._monthsParse[i].test(monthName)) { + return i; + } + } +} + +// MOMENTS + +function setMonth(mom, value) { + if (!mom.isValid()) { + // No op + return mom; + } + + if (typeof value === 'string') { + if (/^\d+$/.test(value)) { + value = toInt(value); + } else { + value = mom.localeData().monthsParse(value); + // TODO: Another silent failure? + if (!isNumber(value)) { + return mom; + } + } + } + + var month = value, + date = mom.date(); + + date = date < 29 ? date : Math.min(date, daysInMonth(mom.year(), month)); + void (mom._isUTC + ? mom._d.setUTCMonth(month, date) + : mom._d.setMonth(month, date)); + return mom; +} + +function getSetMonth(value) { + if (value != null) { + setMonth(this, value); + hooks.updateOffset(this, true); + return this; + } else { + return get(this, 'Month'); + } +} + +function getDaysInMonth() { + return daysInMonth(this.year(), this.month()); +} + +function monthsShortRegex(isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsShortStrictRegex; + } else { + return this._monthsShortRegex; + } + } else { + if (!hasOwnProp(this, '_monthsShortRegex')) { + this._monthsShortRegex = defaultMonthsShortRegex; + } + return this._monthsShortStrictRegex && isStrict + ? this._monthsShortStrictRegex + : this._monthsShortRegex; + } +} + +function monthsRegex(isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsStrictRegex; + } else { + return this._monthsRegex; + } + } else { + if (!hasOwnProp(this, '_monthsRegex')) { + this._monthsRegex = defaultMonthsRegex; + } + return this._monthsStrictRegex && isStrict + ? this._monthsStrictRegex + : this._monthsRegex; + } +} + +function computeMonthsParse() { + function cmpLenRev(a, b) { + return b.length - a.length; + } + + var shortPieces = [], + longPieces = [], + mixedPieces = [], + i, + mom, + shortP, + longP; + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, i]); + shortP = regexEscape(this.monthsShort(mom, '')); + longP = regexEscape(this.months(mom, '')); + shortPieces.push(shortP); + longPieces.push(longP); + mixedPieces.push(longP); + mixedPieces.push(shortP); + } + // Sorting makes sure if one month (or abbr) is a prefix of another it + // will match the longer piece. + shortPieces.sort(cmpLenRev); + longPieces.sort(cmpLenRev); + mixedPieces.sort(cmpLenRev); + + this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._monthsShortRegex = this._monthsRegex; + this._monthsStrictRegex = new RegExp( + '^(' + longPieces.join('|') + ')', + 'i' + ); + this._monthsShortStrictRegex = new RegExp( + '^(' + shortPieces.join('|') + ')', + 'i' + ); +} + +function createDate(y, m, d, h, M, s, ms) { + // can't just apply() to create a date: + // https://stackoverflow.com/q/181348 + var date; + // the date constructor remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + date = new Date(y + 400, m, d, h, M, s, ms); + if (isFinite(date.getFullYear())) { + date.setFullYear(y); + } + } else { + date = new Date(y, m, d, h, M, s, ms); + } + + return date; +} + +function createUTCDate(y) { + var date, args; + // the Date.UTC function remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + args = Array.prototype.slice.call(arguments); + // preserve leap years using a full 400 year cycle, then reset + args[0] = y + 400; + date = new Date(Date.UTC.apply(null, args)); + if (isFinite(date.getUTCFullYear())) { + date.setUTCFullYear(y); + } + } else { + date = new Date(Date.UTC.apply(null, arguments)); + } + + return date; +} + +// start-of-first-week - start-of-year +function firstWeekOffset(year, dow, doy) { + var // first-week day -- which january is always in the first week (4 for iso, 1 for other) + fwd = 7 + dow - doy, + // first-week day local weekday -- which local weekday is fwd + fwdlw = (7 + createUTCDate(year, 0, fwd).getUTCDay() - dow) % 7; + + return -fwdlw + fwd - 1; +} + +// https://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday +function dayOfYearFromWeeks(year, week, weekday, dow, doy) { + var localWeekday = (7 + weekday - dow) % 7, + weekOffset = firstWeekOffset(year, dow, doy), + dayOfYear = 1 + 7 * (week - 1) + localWeekday + weekOffset, + resYear, + resDayOfYear; + + if (dayOfYear <= 0) { + resYear = year - 1; + resDayOfYear = daysInYear(resYear) + dayOfYear; + } else if (dayOfYear > daysInYear(year)) { + resYear = year + 1; + resDayOfYear = dayOfYear - daysInYear(year); + } else { + resYear = year; + resDayOfYear = dayOfYear; + } + + return { + year: resYear, + dayOfYear: resDayOfYear, + }; +} + +function weekOfYear(mom, dow, doy) { + var weekOffset = firstWeekOffset(mom.year(), dow, doy), + week = Math.floor((mom.dayOfYear() - weekOffset - 1) / 7) + 1, + resWeek, + resYear; + + if (week < 1) { + resYear = mom.year() - 1; + resWeek = week + weeksInYear(resYear, dow, doy); + } else if (week > weeksInYear(mom.year(), dow, doy)) { + resWeek = week - weeksInYear(mom.year(), dow, doy); + resYear = mom.year() + 1; + } else { + resYear = mom.year(); + resWeek = week; + } + + return { + week: resWeek, + year: resYear, + }; +} + +function weeksInYear(year, dow, doy) { + var weekOffset = firstWeekOffset(year, dow, doy), + weekOffsetNext = firstWeekOffset(year + 1, dow, doy); + return (daysInYear(year) - weekOffset + weekOffsetNext) / 7; +} + +// FORMATTING + +addFormatToken('w', ['ww', 2], 'wo', 'week'); +addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek'); + +// PARSING + +addRegexToken('w', match1to2, match1to2NoLeadingZero); +addRegexToken('ww', match1to2, match2); +addRegexToken('W', match1to2, match1to2NoLeadingZero); +addRegexToken('WW', match1to2, match2); + +addWeekParseToken( + ['w', 'ww', 'W', 'WW'], + function (input, week, config, token) { + week[token.substr(0, 1)] = toInt(input); + } +); + +// HELPERS + +// LOCALES + +function localeWeek(mom) { + return weekOfYear(mom, this._week.dow, this._week.doy).week; +} + +var defaultLocaleWeek = { + dow: 0, // Sunday is the first day of the week. + doy: 6, // The week that contains Jan 6th is the first week of the year. +}; + +function localeFirstDayOfWeek() { + return this._week.dow; +} + +function localeFirstDayOfYear() { + return this._week.doy; +} + +// MOMENTS + +function getSetWeek(input) { + var week = this.localeData().week(this); + return input == null ? week : this.add((input - week) * 7, 'd'); +} + +function getSetISOWeek(input) { + var week = weekOfYear(this, 1, 4).week; + return input == null ? week : this.add((input - week) * 7, 'd'); +} + +// FORMATTING + +addFormatToken('d', 0, 'do', 'day'); + +addFormatToken('dd', 0, 0, function (format) { + return this.localeData().weekdaysMin(this, format); +}); + +addFormatToken('ddd', 0, 0, function (format) { + return this.localeData().weekdaysShort(this, format); +}); + +addFormatToken('dddd', 0, 0, function (format) { + return this.localeData().weekdays(this, format); +}); + +addFormatToken('e', 0, 0, 'weekday'); +addFormatToken('E', 0, 0, 'isoWeekday'); + +// PARSING + +addRegexToken('d', match1to2); +addRegexToken('e', match1to2); +addRegexToken('E', match1to2); +addRegexToken('dd', function (isStrict, locale) { + return locale.weekdaysMinRegex(isStrict); +}); +addRegexToken('ddd', function (isStrict, locale) { + return locale.weekdaysShortRegex(isStrict); +}); +addRegexToken('dddd', function (isStrict, locale) { + return locale.weekdaysRegex(isStrict); +}); + +addWeekParseToken(['dd', 'ddd', 'dddd'], function (input, week, config, token) { + var weekday = config._locale.weekdaysParse(input, token, config._strict); + // if we didn't get a weekday name, mark the date as invalid + if (weekday != null) { + week.d = weekday; + } else { + getParsingFlags(config).invalidWeekday = input; + } +}); + +addWeekParseToken(['d', 'e', 'E'], function (input, week, config, token) { + week[token] = toInt(input); +}); + +// HELPERS + +function parseWeekday(input, locale) { + if (typeof input !== 'string') { + return input; + } + + if (!isNaN(input)) { + return parseInt(input, 10); + } + + input = locale.weekdaysParse(input); + if (typeof input === 'number') { + return input; + } + + return null; +} + +function parseIsoWeekday(input, locale) { + if (typeof input === 'string') { + return locale.weekdaysParse(input) % 7 || 7; + } + return isNaN(input) ? null : input; +} + +// LOCALES +function shiftWeekdays(ws, n) { + return ws.slice(n, 7).concat(ws.slice(0, n)); +} + +var defaultLocaleWeekdays = + 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), + defaultLocaleWeekdaysShort = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), + defaultLocaleWeekdaysMin = 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'), + defaultWeekdaysRegex = matchWord, + defaultWeekdaysShortRegex = matchWord, + defaultWeekdaysMinRegex = matchWord; + +function localeWeekdays(m, format) { + var weekdays = isArray(this._weekdays) + ? this._weekdays + : this._weekdays[ + m && m !== true && this._weekdays.isFormat.test(format) + ? 'format' + : 'standalone' + ]; + return m === true + ? shiftWeekdays(weekdays, this._week.dow) + : m + ? weekdays[m.day()] + : weekdays; +} + +function localeWeekdaysShort(m) { + return m === true + ? shiftWeekdays(this._weekdaysShort, this._week.dow) + : m + ? this._weekdaysShort[m.day()] + : this._weekdaysShort; +} + +function localeWeekdaysMin(m) { + return m === true + ? shiftWeekdays(this._weekdaysMin, this._week.dow) + : m + ? this._weekdaysMin[m.day()] + : this._weekdaysMin; +} + +function handleStrictParse$1(weekdayName, format, strict) { + var i, + ii, + mom, + llc = weekdayName.toLocaleLowerCase(); + if (!this._weekdaysParse) { + this._weekdaysParse = []; + this._shortWeekdaysParse = []; + this._minWeekdaysParse = []; + + for (i = 0; i < 7; ++i) { + mom = createUTC([2000, 1]).day(i); + this._minWeekdaysParse[i] = this.weekdaysMin( + mom, + '' + ).toLocaleLowerCase(); + this._shortWeekdaysParse[i] = this.weekdaysShort( + mom, + '' + ).toLocaleLowerCase(); + this._weekdaysParse[i] = this.weekdays(mom, '').toLocaleLowerCase(); + } + } + + if (strict) { + if (format === 'dddd') { + ii = indexOf.call(this._weekdaysParse, llc); + return ii !== -1 ? ii : null; + } else if (format === 'ddd') { + ii = indexOf.call(this._shortWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } + } else { + if (format === 'dddd') { + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else if (format === 'ddd') { + ii = indexOf.call(this._shortWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._minWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } + } +} + +function localeWeekdaysParse(weekdayName, format, strict) { + var i, mom, regex; + + if (this._weekdaysParseExact) { + return handleStrictParse$1.call(this, weekdayName, format, strict); + } + + if (!this._weekdaysParse) { + this._weekdaysParse = []; + this._minWeekdaysParse = []; + this._shortWeekdaysParse = []; + this._fullWeekdaysParse = []; + } + + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + + mom = createUTC([2000, 1]).day(i); + if (strict && !this._fullWeekdaysParse[i]) { + this._fullWeekdaysParse[i] = new RegExp( + '^' + this.weekdays(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + this._shortWeekdaysParse[i] = new RegExp( + '^' + this.weekdaysShort(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + this._minWeekdaysParse[i] = new RegExp( + '^' + this.weekdaysMin(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + } + if (!this._weekdaysParse[i]) { + regex = + '^' + + this.weekdays(mom, '') + + '|^' + + this.weekdaysShort(mom, '') + + '|^' + + this.weekdaysMin(mom, ''); + this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if ( + strict && + format === 'dddd' && + this._fullWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if ( + strict && + format === 'ddd' && + this._shortWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if ( + strict && + format === 'dd' && + this._minWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if (!strict && this._weekdaysParse[i].test(weekdayName)) { + return i; + } + } +} + +// MOMENTS + +function getSetDayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + + var day = get(this, 'Day'); + if (input != null) { + input = parseWeekday(input, this.localeData()); + return this.add(input - day, 'd'); + } else { + return day; + } +} + +function getSetLocaleDayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; + return input == null ? weekday : this.add(input - weekday, 'd'); +} + +function getSetISODayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + + // behaves the same as moment#day except + // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) + // as a setter, sunday should belong to the previous week. + + if (input != null) { + var weekday = parseIsoWeekday(input, this.localeData()); + return this.day(this.day() % 7 ? weekday : weekday - 7); + } else { + return this.day() || 7; + } +} + +function weekdaysRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysStrictRegex; + } else { + return this._weekdaysRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysRegex')) { + this._weekdaysRegex = defaultWeekdaysRegex; + } + return this._weekdaysStrictRegex && isStrict + ? this._weekdaysStrictRegex + : this._weekdaysRegex; + } +} + +function weekdaysShortRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysShortStrictRegex; + } else { + return this._weekdaysShortRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysShortRegex')) { + this._weekdaysShortRegex = defaultWeekdaysShortRegex; + } + return this._weekdaysShortStrictRegex && isStrict + ? this._weekdaysShortStrictRegex + : this._weekdaysShortRegex; + } +} + +function weekdaysMinRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysMinStrictRegex; + } else { + return this._weekdaysMinRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysMinRegex')) { + this._weekdaysMinRegex = defaultWeekdaysMinRegex; + } + return this._weekdaysMinStrictRegex && isStrict + ? this._weekdaysMinStrictRegex + : this._weekdaysMinRegex; + } +} + +function computeWeekdaysParse() { + function cmpLenRev(a, b) { + return b.length - a.length; + } + + var minPieces = [], + shortPieces = [], + longPieces = [], + mixedPieces = [], + i, + mom, + minp, + shortp, + longp; + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, 1]).day(i); + minp = regexEscape(this.weekdaysMin(mom, '')); + shortp = regexEscape(this.weekdaysShort(mom, '')); + longp = regexEscape(this.weekdays(mom, '')); + minPieces.push(minp); + shortPieces.push(shortp); + longPieces.push(longp); + mixedPieces.push(minp); + mixedPieces.push(shortp); + mixedPieces.push(longp); + } + // Sorting makes sure if one weekday (or abbr) is a prefix of another it + // will match the longer piece. + minPieces.sort(cmpLenRev); + shortPieces.sort(cmpLenRev); + longPieces.sort(cmpLenRev); + mixedPieces.sort(cmpLenRev); + + this._weekdaysRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._weekdaysShortRegex = this._weekdaysRegex; + this._weekdaysMinRegex = this._weekdaysRegex; + + this._weekdaysStrictRegex = new RegExp( + '^(' + longPieces.join('|') + ')', + 'i' + ); + this._weekdaysShortStrictRegex = new RegExp( + '^(' + shortPieces.join('|') + ')', + 'i' + ); + this._weekdaysMinStrictRegex = new RegExp( + '^(' + minPieces.join('|') + ')', + 'i' + ); +} + +// FORMATTING + +function hFormat() { + return this.hours() % 12 || 12; +} + +function kFormat() { + return this.hours() || 24; +} + +addFormatToken('H', ['HH', 2], 0, 'hour'); +addFormatToken('h', ['hh', 2], 0, hFormat); +addFormatToken('k', ['kk', 2], 0, kFormat); + +addFormatToken('hmm', 0, 0, function () { + return '' + hFormat.apply(this) + zeroFill(this.minutes(), 2); +}); + +addFormatToken('hmmss', 0, 0, function () { + return ( + '' + + hFormat.apply(this) + + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2) + ); +}); + +addFormatToken('Hmm', 0, 0, function () { + return '' + this.hours() + zeroFill(this.minutes(), 2); +}); + +addFormatToken('Hmmss', 0, 0, function () { + return ( + '' + + this.hours() + + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2) + ); +}); + +function meridiem(token, lowercase) { + addFormatToken(token, 0, 0, function () { + return this.localeData().meridiem( + this.hours(), + this.minutes(), + lowercase + ); + }); +} + +meridiem('a', true); +meridiem('A', false); + +// PARSING + +function matchMeridiem(isStrict, locale) { + return locale._meridiemParse; +} + +addRegexToken('a', matchMeridiem); +addRegexToken('A', matchMeridiem); +addRegexToken('H', match1to2, match1to2HasZero); +addRegexToken('h', match1to2, match1to2NoLeadingZero); +addRegexToken('k', match1to2, match1to2NoLeadingZero); +addRegexToken('HH', match1to2, match2); +addRegexToken('hh', match1to2, match2); +addRegexToken('kk', match1to2, match2); + +addRegexToken('hmm', match3to4); +addRegexToken('hmmss', match5to6); +addRegexToken('Hmm', match3to4); +addRegexToken('Hmmss', match5to6); + +addParseToken(['H', 'HH'], HOUR); +addParseToken(['k', 'kk'], function (input, array, config) { + var kInput = toInt(input); + array[HOUR] = kInput === 24 ? 0 : kInput; +}); +addParseToken(['a', 'A'], function (input, array, config) { + config._isPm = config._locale.isPM(input); + config._meridiem = input; +}); +addParseToken(['h', 'hh'], function (input, array, config) { + array[HOUR] = toInt(input); + getParsingFlags(config).bigHour = true; +}); +addParseToken('hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); + getParsingFlags(config).bigHour = true; +}); +addParseToken('hmmss', function (input, array, config) { + var pos1 = input.length - 4, + pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); + getParsingFlags(config).bigHour = true; +}); +addParseToken('Hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); +}); +addParseToken('Hmmss', function (input, array, config) { + var pos1 = input.length - 4, + pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); +}); + +// LOCALES + +function localeIsPM(input) { + // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays + // Using charAt should be more compatible. + return (input + '').toLowerCase().charAt(0) === 'p'; +} + +var defaultLocaleMeridiemParse = /[ap]\.?m?\.?/i, + // Setting the hour should keep the time, because the user explicitly + // specified which hour they want. So trying to maintain the same hour (in + // a new timezone) makes sense. Adding/subtracting hours does not follow + // this rule. + getSetHour = makeGetSet('Hours', true); + +function localeMeridiem(hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } +} + +var baseConfig = { + calendar: defaultCalendar, + longDateFormat: defaultLongDateFormat, + invalidDate: defaultInvalidDate, + ordinal: defaultOrdinal, + dayOfMonthOrdinalParse: defaultDayOfMonthOrdinalParse, + relativeTime: defaultRelativeTime, + + months: defaultLocaleMonths, + monthsShort: defaultLocaleMonthsShort, + + week: defaultLocaleWeek, + + weekdays: defaultLocaleWeekdays, + weekdaysMin: defaultLocaleWeekdaysMin, + weekdaysShort: defaultLocaleWeekdaysShort, + + meridiemParse: defaultLocaleMeridiemParse, +}; + +// internal storage for locale config files +var locales = {}, + localeFamilies = {}, + globalLocale; + +function commonPrefix(arr1, arr2) { + var i, + minl = Math.min(arr1.length, arr2.length); + for (i = 0; i < minl; i += 1) { + if (arr1[i] !== arr2[i]) { + return i; + } + } + return minl; +} + +function normalizeLocale(key) { + return key ? key.toLowerCase().replace('_', '-') : key; +} + +// pick the locale from the array +// try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each +// substring from most specific to least, but move to the next array item if it's a more specific variant than the current root +function chooseLocale(names) { + var i = 0, + j, + next, + locale, + split; + + while (i < names.length) { + split = normalizeLocale(names[i]).split('-'); + j = split.length; + next = normalizeLocale(names[i + 1]); + next = next ? next.split('-') : null; + while (j > 0) { + locale = loadLocale(split.slice(0, j).join('-')); + if (locale) { + return locale; + } + if ( + next && + next.length >= j && + commonPrefix(split, next) >= j - 1 + ) { + //the next array item is better than a shallower substring of this one + break; + } + j--; + } + i++; + } + return globalLocale; +} + +function isLocaleNameSane(name) { + // Prevent names that look like filesystem paths, i.e contain '/' or '\' + // Ensure name is available and function returns boolean + return !!(name && name.match('^[^/\\\\]*$')); +} + +function loadLocale(name) { + var oldLocale = null, + aliasedRequire; + // TODO: Find a better way to register and load all the locales in Node + if ( + locales[name] === undefined && + typeof module !== 'undefined' && + module && + module.exports && + isLocaleNameSane(name) + ) { + try { + oldLocale = globalLocale._abbr; + aliasedRequire = require; + aliasedRequire('./locale/' + name); + getSetGlobalLocale(oldLocale); + } catch (e) { + // mark as not found to avoid repeating expensive file require call causing high CPU + // when trying to find en-US, en_US, en-us for every format call + locales[name] = null; // null means not found + } + } + return locales[name]; +} + +// This function will load locale and then set the global locale. If +// no arguments are passed in, it will simply return the current global +// locale key. +function getSetGlobalLocale(key, values) { + var data; + if (key) { + if (isUndefined(values)) { + data = getLocale(key); + } else { + data = defineLocale(key, values); + } + + if (data) { + // moment.duration._locale = moment._locale = data; + globalLocale = data; + } else { + if (typeof console !== 'undefined' && console.warn) { + //warn user if arguments are passed but the locale could not be set + console.warn( + 'Locale ' + key + ' not found. Did you forget to load it?' + ); + } + } + } + + return globalLocale._abbr; +} + +function defineLocale(name, config) { + if (config !== null) { + var locale, + parentConfig = baseConfig; + config.abbr = name; + if (locales[name] != null) { + deprecateSimple( + 'defineLocaleOverride', + 'use moment.updateLocale(localeName, config) to change ' + + 'an existing locale. moment.defineLocale(localeName, ' + + 'config) should only be used for creating a new locale ' + + 'See http://momentjs.com/guides/#/warnings/define-locale/ for more info.' + ); + parentConfig = locales[name]._config; + } else if (config.parentLocale != null) { + if (locales[config.parentLocale] != null) { + parentConfig = locales[config.parentLocale]._config; + } else { + locale = loadLocale(config.parentLocale); + if (locale != null) { + parentConfig = locale._config; + } else { + if (!localeFamilies[config.parentLocale]) { + localeFamilies[config.parentLocale] = []; + } + localeFamilies[config.parentLocale].push({ + name: name, + config: config, + }); + return null; + } + } + } + locales[name] = new Locale(mergeConfigs(parentConfig, config)); + + if (localeFamilies[name]) { + localeFamilies[name].forEach(function (x) { + defineLocale(x.name, x.config); + }); + } + + // backwards compat for now: also set the locale + // make sure we set the locale AFTER all child locales have been + // created, so we won't end up with the child locale set. + getSetGlobalLocale(name); + + return locales[name]; + } else { + // useful for testing + delete locales[name]; + return null; + } +} + +function updateLocale(name, config) { + if (config != null) { + var locale, + tmpLocale, + parentConfig = baseConfig; + + if (locales[name] != null && locales[name].parentLocale != null) { + // Update existing child locale in-place to avoid memory-leaks + locales[name].set(mergeConfigs(locales[name]._config, config)); + } else { + // MERGE + tmpLocale = loadLocale(name); + if (tmpLocale != null) { + parentConfig = tmpLocale._config; + } + config = mergeConfigs(parentConfig, config); + if (tmpLocale == null) { + // updateLocale is called for creating a new locale + // Set abbr so it will have a name (getters return + // undefined otherwise). + config.abbr = name; + } + locale = new Locale(config); + locale.parentLocale = locales[name]; + locales[name] = locale; + } + + // backwards compat for now: also set the locale + getSetGlobalLocale(name); + } else { + // pass null for config to unupdate, useful for tests + if (locales[name] != null) { + if (locales[name].parentLocale != null) { + locales[name] = locales[name].parentLocale; + if (name === getSetGlobalLocale()) { + getSetGlobalLocale(name); + } + } else if (locales[name] != null) { + delete locales[name]; + } + } + } + return locales[name]; +} + +// returns locale data +function getLocale(key) { + var locale; + + if (key && key._locale && key._locale._abbr) { + key = key._locale._abbr; + } + + if (!key) { + return globalLocale; + } + + if (!isArray(key)) { + //short-circuit everything else + locale = loadLocale(key); + if (locale) { + return locale; + } + key = [key]; + } + + return chooseLocale(key); +} + +function listLocales() { + return keys(locales); +} + +function checkOverflow(m) { + var overflow, + a = m._a; + + if (a && getParsingFlags(m).overflow === -2) { + overflow = + a[MONTH] < 0 || a[MONTH] > 11 + ? MONTH + : a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH]) + ? DATE + : a[HOUR] < 0 || + a[HOUR] > 24 || + (a[HOUR] === 24 && + (a[MINUTE] !== 0 || + a[SECOND] !== 0 || + a[MILLISECOND] !== 0)) + ? HOUR + : a[MINUTE] < 0 || a[MINUTE] > 59 + ? MINUTE + : a[SECOND] < 0 || a[SECOND] > 59 + ? SECOND + : a[MILLISECOND] < 0 || a[MILLISECOND] > 999 + ? MILLISECOND + : -1; + + if ( + getParsingFlags(m)._overflowDayOfYear && + (overflow < YEAR || overflow > DATE) + ) { + overflow = DATE; + } + if (getParsingFlags(m)._overflowWeeks && overflow === -1) { + overflow = WEEK; + } + if (getParsingFlags(m)._overflowWeekday && overflow === -1) { + overflow = WEEKDAY; + } + + getParsingFlags(m).overflow = overflow; + } + + return m; +} + +// iso 8601 regex +// 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) +var extendedIsoRegex = + /^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + basicIsoRegex = + /^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d|))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + tzRegex = /Z|[+-]\d\d(?::?\d\d)?/, + isoDates = [ + ['YYYYYY-MM-DD', /[+-]\d{6}-\d\d-\d\d/], + ['YYYY-MM-DD', /\d{4}-\d\d-\d\d/], + ['GGGG-[W]WW-E', /\d{4}-W\d\d-\d/], + ['GGGG-[W]WW', /\d{4}-W\d\d/, false], + ['YYYY-DDD', /\d{4}-\d{3}/], + ['YYYY-MM', /\d{4}-\d\d/, false], + ['YYYYYYMMDD', /[+-]\d{10}/], + ['YYYYMMDD', /\d{8}/], + ['GGGG[W]WWE', /\d{4}W\d{3}/], + ['GGGG[W]WW', /\d{4}W\d{2}/, false], + ['YYYYDDD', /\d{7}/], + ['YYYYMM', /\d{6}/, false], + ['YYYY', /\d{4}/, false], + ], + // iso time formats and regexes + isoTimes = [ + ['HH:mm:ss.SSSS', /\d\d:\d\d:\d\d\.\d+/], + ['HH:mm:ss,SSSS', /\d\d:\d\d:\d\d,\d+/], + ['HH:mm:ss', /\d\d:\d\d:\d\d/], + ['HH:mm', /\d\d:\d\d/], + ['HHmmss.SSSS', /\d\d\d\d\d\d\.\d+/], + ['HHmmss,SSSS', /\d\d\d\d\d\d,\d+/], + ['HHmmss', /\d\d\d\d\d\d/], + ['HHmm', /\d\d\d\d/], + ['HH', /\d\d/], + ], + aspNetJsonRegex = /^\/?Date\((-?\d+)/i, + // RFC 2822 regex: For details see https://tools.ietf.org/html/rfc2822#section-3.3 + rfc2822 = + /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/, + obsOffsets = { + UT: 0, + GMT: 0, + EDT: -4 * 60, + EST: -5 * 60, + CDT: -5 * 60, + CST: -6 * 60, + MDT: -6 * 60, + MST: -7 * 60, + PDT: -7 * 60, + PST: -8 * 60, + }; + +// date from iso format +function configFromISO(config) { + var i, + l, + string = config._i, + match = extendedIsoRegex.exec(string) || basicIsoRegex.exec(string), + allowTime, + dateFormat, + timeFormat, + tzFormat, + isoDatesLen = isoDates.length, + isoTimesLen = isoTimes.length; + + if (match) { + getParsingFlags(config).iso = true; + for (i = 0, l = isoDatesLen; i < l; i++) { + if (isoDates[i][1].exec(match[1])) { + dateFormat = isoDates[i][0]; + allowTime = isoDates[i][2] !== false; + break; + } + } + if (dateFormat == null) { + config._isValid = false; + return; + } + if (match[3]) { + for (i = 0, l = isoTimesLen; i < l; i++) { + if (isoTimes[i][1].exec(match[3])) { + // match[2] should be 'T' or space + timeFormat = (match[2] || ' ') + isoTimes[i][0]; + break; + } + } + if (timeFormat == null) { + config._isValid = false; + return; + } + } + if (!allowTime && timeFormat != null) { + config._isValid = false; + return; + } + if (match[4]) { + if (tzRegex.exec(match[4])) { + tzFormat = 'Z'; + } else { + config._isValid = false; + return; + } + } + config._f = dateFormat + (timeFormat || '') + (tzFormat || ''); + configFromStringAndFormat(config); + } else { + config._isValid = false; + } +} + +function extractFromRFC2822Strings( + yearStr, + monthStr, + dayStr, + hourStr, + minuteStr, + secondStr +) { + var result = [ + untruncateYear(yearStr), + defaultLocaleMonthsShort.indexOf(monthStr), + parseInt(dayStr, 10), + parseInt(hourStr, 10), + parseInt(minuteStr, 10), + ]; + + if (secondStr) { + result.push(parseInt(secondStr, 10)); + } + + return result; +} + +function untruncateYear(yearStr) { + var year = parseInt(yearStr, 10); + if (year <= 49) { + return 2000 + year; + } else if (year <= 999) { + return 1900 + year; + } + return year; +} + +function preprocessRFC2822(s) { + // Remove comments and folding whitespace and replace multiple-spaces with a single space + return s + .replace(/\([^()]*\)|[\n\t]/g, ' ') + .replace(/(\s\s+)/g, ' ') + .replace(/^\s\s*/, '') + .replace(/\s\s*$/, ''); +} + +function checkWeekday(weekdayStr, parsedInput, config) { + if (weekdayStr) { + // TODO: Replace the vanilla JS Date object with an independent day-of-week check. + var weekdayProvided = defaultLocaleWeekdaysShort.indexOf(weekdayStr), + weekdayActual = new Date( + parsedInput[0], + parsedInput[1], + parsedInput[2] + ).getDay(); + if (weekdayProvided !== weekdayActual) { + getParsingFlags(config).weekdayMismatch = true; + config._isValid = false; + return false; + } + } + return true; +} + +function calculateOffset(obsOffset, militaryOffset, numOffset) { + if (obsOffset) { + return obsOffsets[obsOffset]; + } else if (militaryOffset) { + // the only allowed military tz is Z + return 0; + } else { + var hm = parseInt(numOffset, 10), + m = hm % 100, + h = (hm - m) / 100; + return h * 60 + m; + } +} + +// date and time from ref 2822 format +function configFromRFC2822(config) { + var match = rfc2822.exec(preprocessRFC2822(config._i)), + parsedArray; + if (match) { + parsedArray = extractFromRFC2822Strings( + match[4], + match[3], + match[2], + match[5], + match[6], + match[7] + ); + if (!checkWeekday(match[1], parsedArray, config)) { + return; + } + + config._a = parsedArray; + config._tzm = calculateOffset(match[8], match[9], match[10]); + + config._d = createUTCDate.apply(null, config._a); + config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); + + getParsingFlags(config).rfc2822 = true; + } else { + config._isValid = false; + } +} + +// date from 1) ASP.NET, 2) ISO, 3) RFC 2822 formats, or 4) optional fallback if parsing isn't strict +function configFromString(config) { + var matched = aspNetJsonRegex.exec(config._i); + if (matched !== null) { + config._d = new Date(+matched[1]); + return; + } + + configFromISO(config); + if (config._isValid === false) { + delete config._isValid; + } else { + return; + } + + configFromRFC2822(config); + if (config._isValid === false) { + delete config._isValid; + } else { + return; + } + + if (config._strict) { + config._isValid = false; + } else { + // Final attempt, use Input Fallback + hooks.createFromInputFallback(config); + } +} + +hooks.createFromInputFallback = deprecate( + 'value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), ' + + 'which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are ' + + 'discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.', + function (config) { + config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); + } +); + +// Pick the first defined of two or three arguments. +function defaults(a, b, c) { + if (a != null) { + return a; + } + if (b != null) { + return b; + } + return c; +} + +function currentDateArray(config) { + // hooks is actually the exported moment object + var nowValue = new Date(hooks.now()); + if (config._useUTC) { + return [ + nowValue.getUTCFullYear(), + nowValue.getUTCMonth(), + nowValue.getUTCDate(), + ]; + } + return [nowValue.getFullYear(), nowValue.getMonth(), nowValue.getDate()]; +} + +// convert an array to a date. +// the array should mirror the parameters below +// note: all values past the year are optional and will default to the lowest possible value. +// [year, month, day , hour, minute, second, millisecond] +function configFromArray(config) { + var i, + date, + input = [], + currentDate, + expectedWeekday, + yearToUse; + + if (config._d) { + return; + } + + currentDate = currentDateArray(config); + + //compute day of the year from weeks and weekdays + if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { + dayOfYearFromWeekInfo(config); + } + + //if the day of the year is set, figure out what it is + if (config._dayOfYear != null) { + yearToUse = defaults(config._a[YEAR], currentDate[YEAR]); + + if ( + config._dayOfYear > daysInYear(yearToUse) || + config._dayOfYear === 0 + ) { + getParsingFlags(config)._overflowDayOfYear = true; + } + + date = createUTCDate(yearToUse, 0, config._dayOfYear); + config._a[MONTH] = date.getUTCMonth(); + config._a[DATE] = date.getUTCDate(); + } + + // Default to current date. + // * if no year, month, day of month are given, default to today + // * if day of month is given, default month and year + // * if month is given, default only year + // * if year is given, don't default anything + for (i = 0; i < 3 && config._a[i] == null; ++i) { + config._a[i] = input[i] = currentDate[i]; + } + + // Zero out whatever was not defaulted, including time + for (; i < 7; i++) { + config._a[i] = input[i] = + config._a[i] == null ? (i === 2 ? 1 : 0) : config._a[i]; + } + + // Check for 24:00:00.000 + if ( + config._a[HOUR] === 24 && + config._a[MINUTE] === 0 && + config._a[SECOND] === 0 && + config._a[MILLISECOND] === 0 + ) { + config._nextDay = true; + config._a[HOUR] = 0; + } + + config._d = (config._useUTC ? createUTCDate : createDate).apply( + null, + input + ); + expectedWeekday = config._useUTC + ? config._d.getUTCDay() + : config._d.getDay(); + + // Apply timezone offset from input. The actual utcOffset can be changed + // with parseZone. + if (config._tzm != null) { + config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); + } + + if (config._nextDay) { + config._a[HOUR] = 24; + } + + // check for mismatching day of week + if ( + config._w && + typeof config._w.d !== 'undefined' && + config._w.d !== expectedWeekday + ) { + getParsingFlags(config).weekdayMismatch = true; + } +} + +function dayOfYearFromWeekInfo(config) { + var w, weekYear, week, weekday, dow, doy, temp, weekdayOverflow, curWeek; + + w = config._w; + if (w.GG != null || w.W != null || w.E != null) { + dow = 1; + doy = 4; + + // TODO: We need to take the current isoWeekYear, but that depends on + // how we interpret now (local, utc, fixed offset). So create + // a now version of current config (take local/utc/offset flags, and + // create now). + weekYear = defaults( + w.GG, + config._a[YEAR], + weekOfYear(createLocal(), 1, 4).year + ); + week = defaults(w.W, 1); + weekday = defaults(w.E, 1); + if (weekday < 1 || weekday > 7) { + weekdayOverflow = true; + } + } else { + dow = config._locale._week.dow; + doy = config._locale._week.doy; + + curWeek = weekOfYear(createLocal(), dow, doy); + + weekYear = defaults(w.gg, config._a[YEAR], curWeek.year); + + // Default to current week. + week = defaults(w.w, curWeek.week); + + if (w.d != null) { + // weekday -- low day numbers are considered next week + weekday = w.d; + if (weekday < 0 || weekday > 6) { + weekdayOverflow = true; + } + } else if (w.e != null) { + // local weekday -- counting starts from beginning of week + weekday = w.e + dow; + if (w.e < 0 || w.e > 6) { + weekdayOverflow = true; + } + } else { + // default to beginning of week + weekday = dow; + } + } + if (week < 1 || week > weeksInYear(weekYear, dow, doy)) { + getParsingFlags(config)._overflowWeeks = true; + } else if (weekdayOverflow != null) { + getParsingFlags(config)._overflowWeekday = true; + } else { + temp = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy); + config._a[YEAR] = temp.year; + config._dayOfYear = temp.dayOfYear; + } +} + +// constant that refers to the ISO standard +hooks.ISO_8601 = function () {}; + +// constant that refers to the RFC 2822 form +hooks.RFC_2822 = function () {}; + +// date from string and format string +function configFromStringAndFormat(config) { + // TODO: Move this to another part of the creation flow to prevent circular deps + if (config._f === hooks.ISO_8601) { + configFromISO(config); + return; + } + if (config._f === hooks.RFC_2822) { + configFromRFC2822(config); + return; + } + config._a = []; + getParsingFlags(config).empty = true; + + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var string = '' + config._i, + i, + parsedInput, + tokens, + token, + skipped, + stringLength = string.length, + totalParsedInputLength = 0, + era, + tokenLen; + + tokens = + expandFormat(config._f, config._locale).match(formattingTokens) || []; + tokenLen = tokens.length; + for (i = 0; i < tokenLen; i++) { + token = tokens[i]; + parsedInput = (string.match(getParseRegexForToken(token, config)) || + [])[0]; + if (parsedInput) { + skipped = string.substr(0, string.indexOf(parsedInput)); + if (skipped.length > 0) { + getParsingFlags(config).unusedInput.push(skipped); + } + string = string.slice( + string.indexOf(parsedInput) + parsedInput.length + ); + totalParsedInputLength += parsedInput.length; + } + // don't parse if it's not a known token + if (formatTokenFunctions[token]) { + if (parsedInput) { + getParsingFlags(config).empty = false; + } else { + getParsingFlags(config).unusedTokens.push(token); + } + addTimeToArrayFromToken(token, parsedInput, config); + } else if (config._strict && !parsedInput) { + getParsingFlags(config).unusedTokens.push(token); + } + } + + // add remaining unparsed input length to the string + getParsingFlags(config).charsLeftOver = + stringLength - totalParsedInputLength; + if (string.length > 0) { + getParsingFlags(config).unusedInput.push(string); + } + + // clear _12h flag if hour is <= 12 + if ( + config._a[HOUR] <= 12 && + getParsingFlags(config).bigHour === true && + config._a[HOUR] > 0 + ) { + getParsingFlags(config).bigHour = undefined; + } + + getParsingFlags(config).parsedDateParts = config._a.slice(0); + getParsingFlags(config).meridiem = config._meridiem; + // handle meridiem + config._a[HOUR] = meridiemFixWrap( + config._locale, + config._a[HOUR], + config._meridiem + ); + + // handle era + era = getParsingFlags(config).era; + if (era !== null) { + config._a[YEAR] = config._locale.erasConvertYear(era, config._a[YEAR]); + } + + configFromArray(config); + checkOverflow(config); +} + +function meridiemFixWrap(locale, hour, meridiem) { + var isPm; + + if (meridiem == null) { + // nothing to do + return hour; + } + if (locale.meridiemHour != null) { + return locale.meridiemHour(hour, meridiem); + } else if (locale.isPM != null) { + // Fallback + isPm = locale.isPM(meridiem); + if (isPm && hour < 12) { + hour += 12; + } + if (!isPm && hour === 12) { + hour = 0; + } + return hour; + } else { + // this is not supposed to happen + return hour; + } +} + +// date from string and array of format strings +function configFromStringAndArray(config) { + var tempConfig, + bestMoment, + scoreToBeat, + i, + currentScore, + validFormatFound, + bestFormatIsValid = false, + configfLen = config._f.length; + + if (configfLen === 0) { + getParsingFlags(config).invalidFormat = true; + config._d = new Date(NaN); + return; + } + + for (i = 0; i < configfLen; i++) { + currentScore = 0; + validFormatFound = false; + tempConfig = copyConfig({}, config); + if (config._useUTC != null) { + tempConfig._useUTC = config._useUTC; + } + tempConfig._f = config._f[i]; + configFromStringAndFormat(tempConfig); + + if (isValid(tempConfig)) { + validFormatFound = true; + } + + // if there is any input that was not parsed add a penalty for that format + currentScore += getParsingFlags(tempConfig).charsLeftOver; + + //or tokens + currentScore += getParsingFlags(tempConfig).unusedTokens.length * 10; + + getParsingFlags(tempConfig).score = currentScore; + + if (!bestFormatIsValid) { + if ( + scoreToBeat == null || + currentScore < scoreToBeat || + validFormatFound + ) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + if (validFormatFound) { + bestFormatIsValid = true; + } + } + } else { + if (currentScore < scoreToBeat) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + } + } + } + + extend(config, bestMoment || tempConfig); +} + +function configFromObject(config) { + if (config._d) { + return; + } + + var i = normalizeObjectUnits(config._i), + dayOrDate = i.day === undefined ? i.date : i.day; + config._a = map( + [i.year, i.month, dayOrDate, i.hour, i.minute, i.second, i.millisecond], + function (obj) { + return obj && parseInt(obj, 10); + } + ); + + configFromArray(config); +} + +function createFromConfig(config) { + var res = new Moment(checkOverflow(prepareConfig(config))); + if (res._nextDay) { + // Adding is smart enough around DST + res.add(1, 'd'); + res._nextDay = undefined; + } + + return res; +} + +function prepareConfig(config) { + var input = config._i, + format = config._f; + + config._locale = config._locale || getLocale(config._l); + + if (input === null || (format === undefined && input === '')) { + return createInvalid({ nullInput: true }); + } + + if (typeof input === 'string') { + config._i = input = config._locale.preparse(input); + } + + if (isMoment(input)) { + return new Moment(checkOverflow(input)); + } else if (isDate(input)) { + config._d = input; + } else if (isArray(format)) { + configFromStringAndArray(config); + } else if (format) { + configFromStringAndFormat(config); + } else { + configFromInput(config); + } + + if (!isValid(config)) { + config._d = null; + } + + return config; +} + +function configFromInput(config) { + var input = config._i; + if (isUndefined(input)) { + config._d = new Date(hooks.now()); + } else if (isDate(input)) { + config._d = new Date(input.valueOf()); + } else if (typeof input === 'string') { + configFromString(config); + } else if (isArray(input)) { + config._a = map(input.slice(0), function (obj) { + return parseInt(obj, 10); + }); + configFromArray(config); + } else if (isObject(input)) { + configFromObject(config); + } else if (isNumber(input)) { + // from milliseconds + config._d = new Date(input); + } else { + hooks.createFromInputFallback(config); + } +} + +function createLocalOrUTC(input, format, locale, strict, isUTC) { + var c = {}; + + if (format === true || format === false) { + strict = format; + format = undefined; + } + + if (locale === true || locale === false) { + strict = locale; + locale = undefined; + } + + if ( + (isObject(input) && isObjectEmpty(input)) || + (isArray(input) && input.length === 0) + ) { + input = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c._isAMomentObject = true; + c._useUTC = c._isUTC = isUTC; + c._l = locale; + c._i = input; + c._f = format; + c._strict = strict; + + return createFromConfig(c); +} + +function createLocal(input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, false); +} + +var prototypeMin = deprecate( + 'moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/', + function () { + var other = createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other < this ? this : other; + } else { + return createInvalid(); + } + } + ), + prototypeMax = deprecate( + 'moment().max is deprecated, use moment.min instead. http://momentjs.com/guides/#/warnings/min-max/', + function () { + var other = createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other > this ? this : other; + } else { + return createInvalid(); + } + } + ); + +// Pick a moment m from moments so that m[fn](other) is true for all +// other. This relies on the function fn to be transitive. +// +// moments should either be an array of moment objects or an array, whose +// first element is an array of moment objects. +function pickBy(fn, moments) { + var res, i; + if (moments.length === 1 && isArray(moments[0])) { + moments = moments[0]; + } + if (!moments.length) { + return createLocal(); + } + res = moments[0]; + for (i = 1; i < moments.length; ++i) { + if (!moments[i].isValid() || moments[i][fn](res)) { + res = moments[i]; + } + } + return res; +} + +// TODO: Use [].sort instead? +function min() { + var args = [].slice.call(arguments, 0); + + return pickBy('isBefore', args); +} + +function max() { + var args = [].slice.call(arguments, 0); + + return pickBy('isAfter', args); +} + +var now = function () { + return Date.now ? Date.now() : +new Date(); +}; + +var ordering = [ + 'year', + 'quarter', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'millisecond', +]; + +function isDurationValid(m) { + var key, + unitHasDecimal = false, + i, + orderLen = ordering.length; + for (key in m) { + if ( + hasOwnProp(m, key) && + !( + indexOf.call(ordering, key) !== -1 && + (m[key] == null || !isNaN(m[key])) + ) + ) { + return false; + } + } + + for (i = 0; i < orderLen; ++i) { + if (m[ordering[i]]) { + if (unitHasDecimal) { + return false; // only allow non-integers for smallest unit + } + if (parseFloat(m[ordering[i]]) !== toInt(m[ordering[i]])) { + unitHasDecimal = true; + } + } + } + + return true; +} + +function isValid$1() { + return this._isValid; +} + +function createInvalid$1() { + return createDuration(NaN); +} + +function Duration(duration) { + var normalizedInput = normalizeObjectUnits(duration), + years = normalizedInput.year || 0, + quarters = normalizedInput.quarter || 0, + months = normalizedInput.month || 0, + weeks = normalizedInput.week || normalizedInput.isoWeek || 0, + days = normalizedInput.day || 0, + hours = normalizedInput.hour || 0, + minutes = normalizedInput.minute || 0, + seconds = normalizedInput.second || 0, + milliseconds = normalizedInput.millisecond || 0; + + this._isValid = isDurationValid(normalizedInput); + + // representation for dateAddRemove + this._milliseconds = + +milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 1000 * 60 * 60; //using 1000 * 60 * 60 instead of 36e5 to avoid floating point rounding errors https://github.com/moment/moment/issues/2978 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = +days + weeks * 7; + // It is impossible to translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = +months + quarters * 3 + years * 12; + + this._data = {}; + + this._locale = getLocale(); + + this._bubble(); +} + +function isDuration(obj) { + return obj instanceof Duration; +} + +function absRound(number) { + if (number < 0) { + return Math.round(-1 * number) * -1; + } else { + return Math.round(number); + } +} + +// compare two arrays, return the number of differences +function compareArrays(array1, array2, dontConvert) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if ( + (dontConvert && array1[i] !== array2[i]) || + (!dontConvert && toInt(array1[i]) !== toInt(array2[i])) + ) { + diffs++; + } + } + return diffs + lengthDiff; +} + +// FORMATTING + +function offset(token, separator) { + addFormatToken(token, 0, 0, function () { + var offset = this.utcOffset(), + sign = '+'; + if (offset < 0) { + offset = -offset; + sign = '-'; + } + return ( + sign + + zeroFill(~~(offset / 60), 2) + + separator + + zeroFill(~~offset % 60, 2) + ); + }); +} + +offset('Z', ':'); +offset('ZZ', ''); + +// PARSING + +addRegexToken('Z', matchShortOffset); +addRegexToken('ZZ', matchShortOffset); +addParseToken(['Z', 'ZZ'], function (input, array, config) { + config._useUTC = true; + config._tzm = offsetFromString(matchShortOffset, input); +}); + +// HELPERS + +// timezone chunker +// '+10:00' > ['10', '00'] +// '-1530' > ['-15', '30'] +var chunkOffset = /([\+\-]|\d\d)/gi; + +function offsetFromString(matcher, string) { + var matches = (string || '').match(matcher), + chunk, + parts, + minutes; + + if (matches === null) { + return null; + } + + chunk = matches[matches.length - 1] || []; + parts = (chunk + '').match(chunkOffset) || ['-', 0, 0]; + minutes = +(parts[1] * 60) + toInt(parts[2]); + + return minutes === 0 ? 0 : parts[0] === '+' ? minutes : -minutes; +} + +// Return a moment from input, that is local/utc/zone equivalent to model. +function cloneWithOffset(input, model) { + var res, diff; + if (model._isUTC) { + res = model.clone(); + diff = + (isMoment(input) || isDate(input) + ? input.valueOf() + : createLocal(input).valueOf()) - res.valueOf(); + // Use low-level api, because this fn is low-level api. + res._d.setTime(res._d.valueOf() + diff); + hooks.updateOffset(res, false); + return res; + } else { + return createLocal(input).local(); + } +} + +function getDateOffset(m) { + // On Firefox.24 Date#getTimezoneOffset returns a floating point. + // https://github.com/moment/moment/pull/1871 + return -Math.round(m._d.getTimezoneOffset()); +} + +// HOOKS + +// This function will be called whenever a moment is mutated. +// It is intended to keep the offset in sync with the timezone. +hooks.updateOffset = function () {}; + +// MOMENTS + +// keepLocalTime = true means only change the timezone, without +// affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]--> +// 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset +// +0200, so we adjust the time as needed, to be valid. +// +// Keeping the time actually adds/subtracts (one hour) +// from the actual represented time. That is why we call updateOffset +// a second time. In case it wants us to change the offset again +// _changeInProgress == true case, then we have to adjust, because +// there is no such time in the given timezone. +function getSetOffset(input, keepLocalTime, keepMinutes) { + var offset = this._offset || 0, + localAdjust; + if (!this.isValid()) { + return input != null ? this : NaN; + } + if (input != null) { + if (typeof input === 'string') { + input = offsetFromString(matchShortOffset, input); + if (input === null) { + return this; + } + } else if (Math.abs(input) < 16 && !keepMinutes) { + input = input * 60; + } + if (!this._isUTC && keepLocalTime) { + localAdjust = getDateOffset(this); + } + this._offset = input; + this._isUTC = true; + if (localAdjust != null) { + this.add(localAdjust, 'm'); + } + if (offset !== input) { + if (!keepLocalTime || this._changeInProgress) { + addSubtract( + this, + createDuration(input - offset, 'm'), + 1, + false + ); + } else if (!this._changeInProgress) { + this._changeInProgress = true; + hooks.updateOffset(this, true); + this._changeInProgress = null; + } + } + return this; + } else { + return this._isUTC ? offset : getDateOffset(this); + } +} + +function getSetZone(input, keepLocalTime) { + if (input != null) { + if (typeof input !== 'string') { + input = -input; + } + + this.utcOffset(input, keepLocalTime); + + return this; + } else { + return -this.utcOffset(); + } +} + +function setOffsetToUTC(keepLocalTime) { + return this.utcOffset(0, keepLocalTime); +} + +function setOffsetToLocal(keepLocalTime) { + if (this._isUTC) { + this.utcOffset(0, keepLocalTime); + this._isUTC = false; + + if (keepLocalTime) { + this.subtract(getDateOffset(this), 'm'); + } + } + return this; +} + +function setOffsetToParsedOffset() { + if (this._tzm != null) { + this.utcOffset(this._tzm, false, true); + } else if (typeof this._i === 'string') { + var tZone = offsetFromString(matchOffset, this._i); + if (tZone != null) { + this.utcOffset(tZone); + } else { + this.utcOffset(0, true); + } + } + return this; +} + +function hasAlignedHourOffset(input) { + if (!this.isValid()) { + return false; + } + input = input ? createLocal(input).utcOffset() : 0; + + return (this.utcOffset() - input) % 60 === 0; +} + +function isDaylightSavingTime() { + return ( + this.utcOffset() > this.clone().month(0).utcOffset() || + this.utcOffset() > this.clone().month(5).utcOffset() + ); +} + +function isDaylightSavingTimeShifted() { + if (!isUndefined(this._isDSTShifted)) { + return this._isDSTShifted; + } + + var c = {}, + other; + + copyConfig(c, this); + c = prepareConfig(c); + + if (c._a) { + other = c._isUTC ? createUTC(c._a) : createLocal(c._a); + this._isDSTShifted = + this.isValid() && compareArrays(c._a, other.toArray()) > 0; + } else { + this._isDSTShifted = false; + } + + return this._isDSTShifted; +} + +function isLocal() { + return this.isValid() ? !this._isUTC : false; +} + +function isUtcOffset() { + return this.isValid() ? this._isUTC : false; +} + +function isUtc() { + return this.isValid() ? this._isUTC && this._offset === 0 : false; +} + +// ASP.NET json date format regex +var aspNetRegex = /^(-|\+)?(?:(\d*)[. ])?(\d+):(\d+)(?::(\d+)(\.\d*)?)?$/, + // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html + // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere + // and further modified to allow for strings containing both week and day + isoRegex = + /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; + +function createDuration(input, key) { + var duration = input, + // matching against regexp is expensive, do it on demand + match = null, + sign, + ret, + diffRes; + + if (isDuration(input)) { + duration = { + ms: input._milliseconds, + d: input._days, + M: input._months, + }; + } else if (isNumber(input) || !isNaN(+input)) { + duration = {}; + if (key) { + duration[key] = +input; + } else { + duration.milliseconds = +input; + } + } else if ((match = aspNetRegex.exec(input))) { + sign = match[1] === '-' ? -1 : 1; + duration = { + y: 0, + d: toInt(match[DATE]) * sign, + h: toInt(match[HOUR]) * sign, + m: toInt(match[MINUTE]) * sign, + s: toInt(match[SECOND]) * sign, + ms: toInt(absRound(match[MILLISECOND] * 1000)) * sign, // the millisecond decimal point is included in the match + }; + } else if ((match = isoRegex.exec(input))) { + sign = match[1] === '-' ? -1 : 1; + duration = { + y: parseIso(match[2], sign), + M: parseIso(match[3], sign), + w: parseIso(match[4], sign), + d: parseIso(match[5], sign), + h: parseIso(match[6], sign), + m: parseIso(match[7], sign), + s: parseIso(match[8], sign), + }; + } else if (duration == null) { + // checks for null or undefined + duration = {}; + } else if ( + typeof duration === 'object' && + ('from' in duration || 'to' in duration) + ) { + diffRes = momentsDifference( + createLocal(duration.from), + createLocal(duration.to) + ); + + duration = {}; + duration.ms = diffRes.milliseconds; + duration.M = diffRes.months; + } + + ret = new Duration(duration); + + if (isDuration(input) && hasOwnProp(input, '_locale')) { + ret._locale = input._locale; + } + + if (isDuration(input) && hasOwnProp(input, '_isValid')) { + ret._isValid = input._isValid; + } + + return ret; +} + +createDuration.fn = Duration.prototype; +createDuration.invalid = createInvalid$1; + +function parseIso(inp, sign) { + // We'd normally use ~~inp for this, but unfortunately it also + // converts floats to ints. + // inp may be undefined, so careful calling replace on it. + var res = inp && parseFloat(inp.replace(',', '.')); + // apply sign while we're at it + return (isNaN(res) ? 0 : res) * sign; +} + +function positiveMomentsDifference(base, other) { + var res = {}; + + res.months = + other.month() - base.month() + (other.year() - base.year()) * 12; + if (base.clone().add(res.months, 'M').isAfter(other)) { + --res.months; + } + + res.milliseconds = +other - +base.clone().add(res.months, 'M'); + + return res; +} + +function momentsDifference(base, other) { + var res; + if (!(base.isValid() && other.isValid())) { + return { milliseconds: 0, months: 0 }; + } + + other = cloneWithOffset(other, base); + if (base.isBefore(other)) { + res = positiveMomentsDifference(base, other); + } else { + res = positiveMomentsDifference(other, base); + res.milliseconds = -res.milliseconds; + res.months = -res.months; + } + + return res; +} + +// TODO: remove 'name' arg after deprecation is removed +function createAdder(direction, name) { + return function (val, period) { + var dur, tmp; + //invert the arguments, but complain about it + if (period !== null && !isNaN(+period)) { + deprecateSimple( + name, + 'moment().' + + name + + '(period, number) is deprecated. Please use moment().' + + name + + '(number, period). ' + + 'See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info.' + ); + tmp = val; + val = period; + period = tmp; + } + + dur = createDuration(val, period); + addSubtract(this, dur, direction); + return this; + }; +} + +function addSubtract(mom, duration, isAdding, updateOffset) { + var milliseconds = duration._milliseconds, + days = absRound(duration._days), + months = absRound(duration._months); + + if (!mom.isValid()) { + // No op + return; + } + + updateOffset = updateOffset == null ? true : updateOffset; + + if (months) { + setMonth(mom, get(mom, 'Month') + months * isAdding); + } + if (days) { + set$1(mom, 'Date', get(mom, 'Date') + days * isAdding); + } + if (milliseconds) { + mom._d.setTime(mom._d.valueOf() + milliseconds * isAdding); + } + if (updateOffset) { + hooks.updateOffset(mom, days || months); + } +} + +var add = createAdder(1, 'add'), + subtract = createAdder(-1, 'subtract'); + +function isString(input) { + return typeof input === 'string' || input instanceof String; +} + +// type MomentInput = Moment | Date | string | number | (number | string)[] | MomentInputObject | void; // null | undefined +function isMomentInput(input) { + return ( + isMoment(input) || + isDate(input) || + isString(input) || + isNumber(input) || + isNumberOrStringArray(input) || + isMomentInputObject(input) || + input === null || + input === undefined + ); +} + +function isMomentInputObject(input) { + var objectTest = isObject(input) && !isObjectEmpty(input), + propertyTest = false, + properties = [ + 'years', + 'year', + 'y', + 'months', + 'month', + 'M', + 'days', + 'day', + 'd', + 'dates', + 'date', + 'D', + 'hours', + 'hour', + 'h', + 'minutes', + 'minute', + 'm', + 'seconds', + 'second', + 's', + 'milliseconds', + 'millisecond', + 'ms', + ], + i, + property, + propertyLen = properties.length; + + for (i = 0; i < propertyLen; i += 1) { + property = properties[i]; + propertyTest = propertyTest || hasOwnProp(input, property); + } + + return objectTest && propertyTest; +} + +function isNumberOrStringArray(input) { + var arrayTest = isArray(input), + dataTypeTest = false; + if (arrayTest) { + dataTypeTest = + input.filter(function (item) { + return !isNumber(item) && isString(input); + }).length === 0; + } + return arrayTest && dataTypeTest; +} + +function isCalendarSpec(input) { + var objectTest = isObject(input) && !isObjectEmpty(input), + propertyTest = false, + properties = [ + 'sameDay', + 'nextDay', + 'lastDay', + 'nextWeek', + 'lastWeek', + 'sameElse', + ], + i, + property; + + for (i = 0; i < properties.length; i += 1) { + property = properties[i]; + propertyTest = propertyTest || hasOwnProp(input, property); + } + + return objectTest && propertyTest; +} + +function getCalendarFormat(myMoment, now) { + var diff = myMoment.diff(now, 'days', true); + return diff < -6 + ? 'sameElse' + : diff < -1 + ? 'lastWeek' + : diff < 0 + ? 'lastDay' + : diff < 1 + ? 'sameDay' + : diff < 2 + ? 'nextDay' + : diff < 7 + ? 'nextWeek' + : 'sameElse'; +} + +function calendar$1(time, formats) { + // Support for single parameter, formats only overload to the calendar function + if (arguments.length === 1) { + if (!arguments[0]) { + time = undefined; + formats = undefined; + } else if (isMomentInput(arguments[0])) { + time = arguments[0]; + formats = undefined; + } else if (isCalendarSpec(arguments[0])) { + formats = arguments[0]; + time = undefined; + } + } + // We want to compare the start of today, vs this. + // Getting start-of-today depends on whether we're local/utc/offset or not. + var now = time || createLocal(), + sod = cloneWithOffset(now, this).startOf('day'), + format = hooks.calendarFormat(this, sod) || 'sameElse', + output = + formats && + (isFunction(formats[format]) + ? formats[format].call(this, now) + : formats[format]); + + return this.format( + output || this.localeData().calendar(format, this, createLocal(now)) + ); +} + +function clone() { + return new Moment(this); +} + +function isAfter(input, units) { + var localInput = isMoment(input) ? input : createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() > localInput.valueOf(); + } else { + return localInput.valueOf() < this.clone().startOf(units).valueOf(); + } +} + +function isBefore(input, units) { + var localInput = isMoment(input) ? input : createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() < localInput.valueOf(); + } else { + return this.clone().endOf(units).valueOf() < localInput.valueOf(); + } +} + +function isBetween(from, to, units, inclusivity) { + var localFrom = isMoment(from) ? from : createLocal(from), + localTo = isMoment(to) ? to : createLocal(to); + if (!(this.isValid() && localFrom.isValid() && localTo.isValid())) { + return false; + } + inclusivity = inclusivity || '()'; + return ( + (inclusivity[0] === '(' + ? this.isAfter(localFrom, units) + : !this.isBefore(localFrom, units)) && + (inclusivity[1] === ')' + ? this.isBefore(localTo, units) + : !this.isAfter(localTo, units)) + ); +} + +function isSame(input, units) { + var localInput = isMoment(input) ? input : createLocal(input), + inputMs; + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() === localInput.valueOf(); + } else { + inputMs = localInput.valueOf(); + return ( + this.clone().startOf(units).valueOf() <= inputMs && + inputMs <= this.clone().endOf(units).valueOf() + ); + } +} + +function isSameOrAfter(input, units) { + return this.isSame(input, units) || this.isAfter(input, units); +} + +function isSameOrBefore(input, units) { + return this.isSame(input, units) || this.isBefore(input, units); +} + +function diff(input, units, asFloat) { + var that, zoneDelta, output; + + if (!this.isValid()) { + return NaN; + } + + that = cloneWithOffset(input, this); + + if (!that.isValid()) { + return NaN; + } + + zoneDelta = (that.utcOffset() - this.utcOffset()) * 6e4; + + units = normalizeUnits(units); + + switch (units) { + case 'year': + output = monthDiff(this, that) / 12; + break; + case 'month': + output = monthDiff(this, that); + break; + case 'quarter': + output = monthDiff(this, that) / 3; + break; + case 'second': + output = (this - that) / 1e3; + break; // 1000 + case 'minute': + output = (this - that) / 6e4; + break; // 1000 * 60 + case 'hour': + output = (this - that) / 36e5; + break; // 1000 * 60 * 60 + case 'day': + output = (this - that - zoneDelta) / 864e5; + break; // 1000 * 60 * 60 * 24, negate dst + case 'week': + output = (this - that - zoneDelta) / 6048e5; + break; // 1000 * 60 * 60 * 24 * 7, negate dst + default: + output = this - that; + } + + return asFloat ? output : absFloor(output); +} + +function monthDiff(a, b) { + if (a.date() < b.date()) { + // end-of-month calculations work correct when the start month has more + // days than the end month. + return -monthDiff(b, a); + } + // difference in months + var wholeMonthDiff = (b.year() - a.year()) * 12 + (b.month() - a.month()), + // b is in (anchor - 1 month, anchor + 1 month) + anchor = a.clone().add(wholeMonthDiff, 'months'), + anchor2, + adjust; + + if (b - anchor < 0) { + anchor2 = a.clone().add(wholeMonthDiff - 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor - anchor2); + } else { + anchor2 = a.clone().add(wholeMonthDiff + 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor2 - anchor); + } + + //check for negative zero, return zero if negative zero + return -(wholeMonthDiff + adjust) || 0; +} + +hooks.defaultFormat = 'YYYY-MM-DDTHH:mm:ssZ'; +hooks.defaultFormatUtc = 'YYYY-MM-DDTHH:mm:ss[Z]'; + +function toString() { + return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); +} + +function toISOString(keepOffset) { + if (!this.isValid()) { + return null; + } + var utc = keepOffset !== true, + m = utc ? this.clone().utc() : this; + if (m.year() < 0 || m.year() > 9999) { + return formatMoment( + m, + utc + ? 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]' + : 'YYYYYY-MM-DD[T]HH:mm:ss.SSSZ' + ); + } + if (isFunction(Date.prototype.toISOString)) { + // native implementation is ~50x faster, use it when we can + if (utc) { + return this.toDate().toISOString(); + } else { + return new Date(this.valueOf() + this.utcOffset() * 60 * 1000) + .toISOString() + .replace('Z', formatMoment(m, 'Z')); + } + } + return formatMoment( + m, + utc ? 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]' : 'YYYY-MM-DD[T]HH:mm:ss.SSSZ' + ); +} + +/** + * Return a human readable representation of a moment that can + * also be evaluated to get a new moment which is the same + * + * @link https://nodejs.org/dist/latest/docs/api/util.html#util_custom_inspect_function_on_objects + */ +function inspect() { + if (!this.isValid()) { + return 'moment.invalid(/* ' + this._i + ' */)'; + } + var func = 'moment', + zone = '', + prefix, + year, + datetime, + suffix; + if (!this.isLocal()) { + func = this.utcOffset() === 0 ? 'moment.utc' : 'moment.parseZone'; + zone = 'Z'; + } + prefix = '[' + func + '("]'; + year = 0 <= this.year() && this.year() <= 9999 ? 'YYYY' : 'YYYYYY'; + datetime = '-MM-DD[T]HH:mm:ss.SSS'; + suffix = zone + '[")]'; + + return this.format(prefix + year + datetime + suffix); +} + +function format(inputString) { + if (!inputString) { + inputString = this.isUtc() + ? hooks.defaultFormatUtc + : hooks.defaultFormat; + } + var output = formatMoment(this, inputString); + return this.localeData().postformat(output); +} + +function from(time, withoutSuffix) { + if ( + this.isValid() && + ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) + ) { + return createDuration({ to: this, from: time }) + .locale(this.locale()) + .humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } +} + +function fromNow(withoutSuffix) { + return this.from(createLocal(), withoutSuffix); +} + +function to(time, withoutSuffix) { + if ( + this.isValid() && + ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) + ) { + return createDuration({ from: this, to: time }) + .locale(this.locale()) + .humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } +} + +function toNow(withoutSuffix) { + return this.to(createLocal(), withoutSuffix); +} + +// If passed a locale key, it will set the locale for this +// instance. Otherwise, it will return the locale configuration +// variables for this instance. +function locale(key) { + var newLocaleData; + + if (key === undefined) { + return this._locale._abbr; + } else { + newLocaleData = getLocale(key); + if (newLocaleData != null) { + this._locale = newLocaleData; + } + return this; + } +} + +var lang = deprecate( + 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', + function (key) { + if (key === undefined) { + return this.localeData(); + } else { + return this.locale(key); + } + } +); + +function localeData() { + return this._locale; +} + +var MS_PER_SECOND = 1000, + MS_PER_MINUTE = 60 * MS_PER_SECOND, + MS_PER_HOUR = 60 * MS_PER_MINUTE, + MS_PER_400_YEARS = (365 * 400 + 97) * 24 * MS_PER_HOUR; + +// actual modulo - handles negative numbers (for dates before 1970): +function mod$1(dividend, divisor) { + return ((dividend % divisor) + divisor) % divisor; +} + +function localStartOfDate(y, m, d) { + // the date constructor remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + return new Date(y + 400, m, d) - MS_PER_400_YEARS; + } else { + return new Date(y, m, d).valueOf(); + } +} + +function utcStartOfDate(y, m, d) { + // Date.UTC remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + return Date.UTC(y + 400, m, d) - MS_PER_400_YEARS; + } else { + return Date.UTC(y, m, d); + } +} + +function startOf(units) { + var time, startOfDate; + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond' || !this.isValid()) { + return this; + } + + startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; + + switch (units) { + case 'year': + time = startOfDate(this.year(), 0, 1); + break; + case 'quarter': + time = startOfDate( + this.year(), + this.month() - (this.month() % 3), + 1 + ); + break; + case 'month': + time = startOfDate(this.year(), this.month(), 1); + break; + case 'week': + time = startOfDate( + this.year(), + this.month(), + this.date() - this.weekday() + ); + break; + case 'isoWeek': + time = startOfDate( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + ); + break; + case 'day': + case 'date': + time = startOfDate(this.year(), this.month(), this.date()); + break; + case 'hour': + time = this._d.valueOf(); + time -= mod$1( + time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), + MS_PER_HOUR + ); + break; + case 'minute': + time = this._d.valueOf(); + time -= mod$1(time, MS_PER_MINUTE); + break; + case 'second': + time = this._d.valueOf(); + time -= mod$1(time, MS_PER_SECOND); + break; + } + + this._d.setTime(time); + hooks.updateOffset(this, true); + return this; +} + +function endOf(units) { + var time, startOfDate; + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond' || !this.isValid()) { + return this; + } + + startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; + + switch (units) { + case 'year': + time = startOfDate(this.year() + 1, 0, 1) - 1; + break; + case 'quarter': + time = + startOfDate( + this.year(), + this.month() - (this.month() % 3) + 3, + 1 + ) - 1; + break; + case 'month': + time = startOfDate(this.year(), this.month() + 1, 1) - 1; + break; + case 'week': + time = + startOfDate( + this.year(), + this.month(), + this.date() - this.weekday() + 7 + ) - 1; + break; + case 'isoWeek': + time = + startOfDate( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + 7 + ) - 1; + break; + case 'day': + case 'date': + time = startOfDate(this.year(), this.month(), this.date() + 1) - 1; + break; + case 'hour': + time = this._d.valueOf(); + time += + MS_PER_HOUR - + mod$1( + time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), + MS_PER_HOUR + ) - + 1; + break; + case 'minute': + time = this._d.valueOf(); + time += MS_PER_MINUTE - mod$1(time, MS_PER_MINUTE) - 1; + break; + case 'second': + time = this._d.valueOf(); + time += MS_PER_SECOND - mod$1(time, MS_PER_SECOND) - 1; + break; + } + + this._d.setTime(time); + hooks.updateOffset(this, true); + return this; +} + +function valueOf() { + return this._d.valueOf() - (this._offset || 0) * 60000; +} + +function unix() { + return Math.floor(this.valueOf() / 1000); +} + +function toDate() { + return new Date(this.valueOf()); +} + +function toArray() { + var m = this; + return [ + m.year(), + m.month(), + m.date(), + m.hour(), + m.minute(), + m.second(), + m.millisecond(), + ]; +} + +function toObject() { + var m = this; + return { + years: m.year(), + months: m.month(), + date: m.date(), + hours: m.hours(), + minutes: m.minutes(), + seconds: m.seconds(), + milliseconds: m.milliseconds(), + }; +} + +function toJSON() { + // new Date(NaN).toJSON() === null + return this.isValid() ? this.toISOString() : null; +} + +function isValid$2() { + return isValid(this); +} + +function parsingFlags() { + return extend({}, getParsingFlags(this)); +} + +function invalidAt() { + return getParsingFlags(this).overflow; +} + +function creationData() { + return { + input: this._i, + format: this._f, + locale: this._locale, + isUTC: this._isUTC, + strict: this._strict, + }; +} + +addFormatToken('N', 0, 0, 'eraAbbr'); +addFormatToken('NN', 0, 0, 'eraAbbr'); +addFormatToken('NNN', 0, 0, 'eraAbbr'); +addFormatToken('NNNN', 0, 0, 'eraName'); +addFormatToken('NNNNN', 0, 0, 'eraNarrow'); + +addFormatToken('y', ['y', 1], 'yo', 'eraYear'); +addFormatToken('y', ['yy', 2], 0, 'eraYear'); +addFormatToken('y', ['yyy', 3], 0, 'eraYear'); +addFormatToken('y', ['yyyy', 4], 0, 'eraYear'); + +addRegexToken('N', matchEraAbbr); +addRegexToken('NN', matchEraAbbr); +addRegexToken('NNN', matchEraAbbr); +addRegexToken('NNNN', matchEraName); +addRegexToken('NNNNN', matchEraNarrow); + +addParseToken( + ['N', 'NN', 'NNN', 'NNNN', 'NNNNN'], + function (input, array, config, token) { + var era = config._locale.erasParse(input, token, config._strict); + if (era) { + getParsingFlags(config).era = era; + } else { + getParsingFlags(config).invalidEra = input; + } + } +); + +addRegexToken('y', matchUnsigned); +addRegexToken('yy', matchUnsigned); +addRegexToken('yyy', matchUnsigned); +addRegexToken('yyyy', matchUnsigned); +addRegexToken('yo', matchEraYearOrdinal); + +addParseToken(['y', 'yy', 'yyy', 'yyyy'], YEAR); +addParseToken(['yo'], function (input, array, config, token) { + var match; + if (config._locale._eraYearOrdinalRegex) { + match = input.match(config._locale._eraYearOrdinalRegex); + } + + if (config._locale.eraYearOrdinalParse) { + array[YEAR] = config._locale.eraYearOrdinalParse(input, match); + } else { + array[YEAR] = parseInt(input, 10); + } +}); + +function localeEras(m, format) { + var i, + l, + date, + eras = this._eras || getLocale('en')._eras; + for (i = 0, l = eras.length; i < l; ++i) { + switch (typeof eras[i].since) { + case 'string': + // truncate time + date = hooks(eras[i].since).startOf('day'); + eras[i].since = date.valueOf(); + break; + } + + switch (typeof eras[i].until) { + case 'undefined': + eras[i].until = +Infinity; + break; + case 'string': + // truncate time + date = hooks(eras[i].until).startOf('day').valueOf(); + eras[i].until = date.valueOf(); + break; + } + } + return eras; +} + +function localeErasParse(eraName, format, strict) { + var i, + l, + eras = this.eras(), + name, + abbr, + narrow; + eraName = eraName.toUpperCase(); + + for (i = 0, l = eras.length; i < l; ++i) { + name = eras[i].name.toUpperCase(); + abbr = eras[i].abbr.toUpperCase(); + narrow = eras[i].narrow.toUpperCase(); + + if (strict) { + switch (format) { + case 'N': + case 'NN': + case 'NNN': + if (abbr === eraName) { + return eras[i]; + } + break; + + case 'NNNN': + if (name === eraName) { + return eras[i]; + } + break; + + case 'NNNNN': + if (narrow === eraName) { + return eras[i]; + } + break; + } + } else if ([name, abbr, narrow].indexOf(eraName) >= 0) { + return eras[i]; + } + } +} + +function localeErasConvertYear(era, year) { + var dir = era.since <= era.until ? +1 : -1; + if (year === undefined) { + return hooks(era.since).year(); + } else { + return hooks(era.since).year() + (year - era.offset) * dir; + } +} + +function getEraName() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].name; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].name; + } + } + + return ''; +} + +function getEraNarrow() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].narrow; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].narrow; + } + } + + return ''; +} + +function getEraAbbr() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].abbr; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].abbr; + } + } + + return ''; +} + +function getEraYear() { + var i, + l, + dir, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + dir = eras[i].since <= eras[i].until ? +1 : -1; + + // truncate time + val = this.clone().startOf('day').valueOf(); + + if ( + (eras[i].since <= val && val <= eras[i].until) || + (eras[i].until <= val && val <= eras[i].since) + ) { + return ( + (this.year() - hooks(eras[i].since).year()) * dir + + eras[i].offset + ); + } + } + + return this.year(); +} + +function erasNameRegex(isStrict) { + if (!hasOwnProp(this, '_erasNameRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasNameRegex : this._erasRegex; +} + +function erasAbbrRegex(isStrict) { + if (!hasOwnProp(this, '_erasAbbrRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasAbbrRegex : this._erasRegex; +} + +function erasNarrowRegex(isStrict) { + if (!hasOwnProp(this, '_erasNarrowRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasNarrowRegex : this._erasRegex; +} + +function matchEraAbbr(isStrict, locale) { + return locale.erasAbbrRegex(isStrict); +} + +function matchEraName(isStrict, locale) { + return locale.erasNameRegex(isStrict); +} + +function matchEraNarrow(isStrict, locale) { + return locale.erasNarrowRegex(isStrict); +} + +function matchEraYearOrdinal(isStrict, locale) { + return locale._eraYearOrdinalRegex || matchUnsigned; +} + +function computeErasParse() { + var abbrPieces = [], + namePieces = [], + narrowPieces = [], + mixedPieces = [], + i, + l, + erasName, + erasAbbr, + erasNarrow, + eras = this.eras(); + + for (i = 0, l = eras.length; i < l; ++i) { + erasName = regexEscape(eras[i].name); + erasAbbr = regexEscape(eras[i].abbr); + erasNarrow = regexEscape(eras[i].narrow); + + namePieces.push(erasName); + abbrPieces.push(erasAbbr); + narrowPieces.push(erasNarrow); + mixedPieces.push(erasName); + mixedPieces.push(erasAbbr); + mixedPieces.push(erasNarrow); + } + + this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._erasNameRegex = new RegExp('^(' + namePieces.join('|') + ')', 'i'); + this._erasAbbrRegex = new RegExp('^(' + abbrPieces.join('|') + ')', 'i'); + this._erasNarrowRegex = new RegExp( + '^(' + narrowPieces.join('|') + ')', + 'i' + ); +} + +// FORMATTING + +addFormatToken(0, ['gg', 2], 0, function () { + return this.weekYear() % 100; +}); + +addFormatToken(0, ['GG', 2], 0, function () { + return this.isoWeekYear() % 100; +}); + +function addWeekYearFormatToken(token, getter) { + addFormatToken(0, [token, token.length], 0, getter); +} + +addWeekYearFormatToken('gggg', 'weekYear'); +addWeekYearFormatToken('ggggg', 'weekYear'); +addWeekYearFormatToken('GGGG', 'isoWeekYear'); +addWeekYearFormatToken('GGGGG', 'isoWeekYear'); + +// ALIASES + +// PARSING + +addRegexToken('G', matchSigned); +addRegexToken('g', matchSigned); +addRegexToken('GG', match1to2, match2); +addRegexToken('gg', match1to2, match2); +addRegexToken('GGGG', match1to4, match4); +addRegexToken('gggg', match1to4, match4); +addRegexToken('GGGGG', match1to6, match6); +addRegexToken('ggggg', match1to6, match6); + +addWeekParseToken( + ['gggg', 'ggggg', 'GGGG', 'GGGGG'], + function (input, week, config, token) { + week[token.substr(0, 2)] = toInt(input); + } +); + +addWeekParseToken(['gg', 'GG'], function (input, week, config, token) { + week[token] = hooks.parseTwoDigitYear(input); +}); + +// MOMENTS + +function getSetWeekYear(input) { + return getSetWeekYearHelper.call( + this, + input, + this.week(), + this.weekday() + this.localeData()._week.dow, + this.localeData()._week.dow, + this.localeData()._week.doy + ); +} + +function getSetISOWeekYear(input) { + return getSetWeekYearHelper.call( + this, + input, + this.isoWeek(), + this.isoWeekday(), + 1, + 4 + ); +} + +function getISOWeeksInYear() { + return weeksInYear(this.year(), 1, 4); +} + +function getISOWeeksInISOWeekYear() { + return weeksInYear(this.isoWeekYear(), 1, 4); +} + +function getWeeksInYear() { + var weekInfo = this.localeData()._week; + return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); +} + +function getWeeksInWeekYear() { + var weekInfo = this.localeData()._week; + return weeksInYear(this.weekYear(), weekInfo.dow, weekInfo.doy); +} + +function getSetWeekYearHelper(input, week, weekday, dow, doy) { + var weeksTarget; + if (input == null) { + return weekOfYear(this, dow, doy).year; + } else { + weeksTarget = weeksInYear(input, dow, doy); + if (week > weeksTarget) { + week = weeksTarget; + } + return setWeekAll.call(this, input, week, weekday, dow, doy); + } +} + +function setWeekAll(weekYear, week, weekday, dow, doy) { + var dayOfYearData = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy), + date = createUTCDate(dayOfYearData.year, 0, dayOfYearData.dayOfYear); + + this.year(date.getUTCFullYear()); + this.month(date.getUTCMonth()); + this.date(date.getUTCDate()); + return this; +} + +// FORMATTING + +addFormatToken('Q', 0, 'Qo', 'quarter'); + +// PARSING + +addRegexToken('Q', match1); +addParseToken('Q', function (input, array) { + array[MONTH] = (toInt(input) - 1) * 3; +}); + +// MOMENTS + +function getSetQuarter(input) { + return input == null + ? Math.ceil((this.month() + 1) / 3) + : this.month((input - 1) * 3 + (this.month() % 3)); +} + +// FORMATTING + +addFormatToken('D', ['DD', 2], 'Do', 'date'); + +// PARSING + +addRegexToken('D', match1to2, match1to2NoLeadingZero); +addRegexToken('DD', match1to2, match2); +addRegexToken('Do', function (isStrict, locale) { + // TODO: Remove "ordinalParse" fallback in next major release. + return isStrict + ? locale._dayOfMonthOrdinalParse || locale._ordinalParse + : locale._dayOfMonthOrdinalParseLenient; +}); + +addParseToken(['D', 'DD'], DATE); +addParseToken('Do', function (input, array) { + array[DATE] = toInt(input.match(match1to2)[0]); +}); + +// MOMENTS + +var getSetDayOfMonth = makeGetSet('Date', true); + +// FORMATTING + +addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'); + +// PARSING + +addRegexToken('DDD', match1to3); +addRegexToken('DDDD', match3); +addParseToken(['DDD', 'DDDD'], function (input, array, config) { + config._dayOfYear = toInt(input); +}); + +// HELPERS + +// MOMENTS + +function getSetDayOfYear(input) { + var dayOfYear = + Math.round( + (this.clone().startOf('day') - this.clone().startOf('year')) / 864e5 + ) + 1; + return input == null ? dayOfYear : this.add(input - dayOfYear, 'd'); +} + +// FORMATTING + +addFormatToken('m', ['mm', 2], 0, 'minute'); + +// PARSING + +addRegexToken('m', match1to2, match1to2HasZero); +addRegexToken('mm', match1to2, match2); +addParseToken(['m', 'mm'], MINUTE); + +// MOMENTS + +var getSetMinute = makeGetSet('Minutes', false); + +// FORMATTING + +addFormatToken('s', ['ss', 2], 0, 'second'); + +// PARSING + +addRegexToken('s', match1to2, match1to2HasZero); +addRegexToken('ss', match1to2, match2); +addParseToken(['s', 'ss'], SECOND); + +// MOMENTS + +var getSetSecond = makeGetSet('Seconds', false); + +// FORMATTING + +addFormatToken('S', 0, 0, function () { + return ~~(this.millisecond() / 100); +}); + +addFormatToken(0, ['SS', 2], 0, function () { + return ~~(this.millisecond() / 10); +}); + +addFormatToken(0, ['SSS', 3], 0, 'millisecond'); +addFormatToken(0, ['SSSS', 4], 0, function () { + return this.millisecond() * 10; +}); +addFormatToken(0, ['SSSSS', 5], 0, function () { + return this.millisecond() * 100; +}); +addFormatToken(0, ['SSSSSS', 6], 0, function () { + return this.millisecond() * 1000; +}); +addFormatToken(0, ['SSSSSSS', 7], 0, function () { + return this.millisecond() * 10000; +}); +addFormatToken(0, ['SSSSSSSS', 8], 0, function () { + return this.millisecond() * 100000; +}); +addFormatToken(0, ['SSSSSSSSS', 9], 0, function () { + return this.millisecond() * 1000000; +}); + +// PARSING + +addRegexToken('S', match1to3, match1); +addRegexToken('SS', match1to3, match2); +addRegexToken('SSS', match1to3, match3); + +var token, getSetMillisecond; +for (token = 'SSSS'; token.length <= 9; token += 'S') { + addRegexToken(token, matchUnsigned); +} + +function parseMs(input, array) { + array[MILLISECOND] = toInt(('0.' + input) * 1000); +} + +for (token = 'S'; token.length <= 9; token += 'S') { + addParseToken(token, parseMs); +} + +getSetMillisecond = makeGetSet('Milliseconds', false); + +// FORMATTING + +addFormatToken('z', 0, 0, 'zoneAbbr'); +addFormatToken('zz', 0, 0, 'zoneName'); + +// MOMENTS + +function getZoneAbbr() { + return this._isUTC ? 'UTC' : ''; +} + +function getZoneName() { + return this._isUTC ? 'Coordinated Universal Time' : ''; +} + +var proto = Moment.prototype; + +proto.add = add; +proto.calendar = calendar$1; +proto.clone = clone; +proto.diff = diff; +proto.endOf = endOf; +proto.format = format; +proto.from = from; +proto.fromNow = fromNow; +proto.to = to; +proto.toNow = toNow; +proto.get = stringGet; +proto.invalidAt = invalidAt; +proto.isAfter = isAfter; +proto.isBefore = isBefore; +proto.isBetween = isBetween; +proto.isSame = isSame; +proto.isSameOrAfter = isSameOrAfter; +proto.isSameOrBefore = isSameOrBefore; +proto.isValid = isValid$2; +proto.lang = lang; +proto.locale = locale; +proto.localeData = localeData; +proto.max = prototypeMax; +proto.min = prototypeMin; +proto.parsingFlags = parsingFlags; +proto.set = stringSet; +proto.startOf = startOf; +proto.subtract = subtract; +proto.toArray = toArray; +proto.toObject = toObject; +proto.toDate = toDate; +proto.toISOString = toISOString; +proto.inspect = inspect; +if (typeof Symbol !== 'undefined' && Symbol.for != null) { + proto[Symbol.for('nodejs.util.inspect.custom')] = function () { + return 'Moment<' + this.format() + '>'; + }; +} +proto.toJSON = toJSON; +proto.toString = toString; +proto.unix = unix; +proto.valueOf = valueOf; +proto.creationData = creationData; +proto.eraName = getEraName; +proto.eraNarrow = getEraNarrow; +proto.eraAbbr = getEraAbbr; +proto.eraYear = getEraYear; +proto.year = getSetYear; +proto.isLeapYear = getIsLeapYear; +proto.weekYear = getSetWeekYear; +proto.isoWeekYear = getSetISOWeekYear; +proto.quarter = proto.quarters = getSetQuarter; +proto.month = getSetMonth; +proto.daysInMonth = getDaysInMonth; +proto.week = proto.weeks = getSetWeek; +proto.isoWeek = proto.isoWeeks = getSetISOWeek; +proto.weeksInYear = getWeeksInYear; +proto.weeksInWeekYear = getWeeksInWeekYear; +proto.isoWeeksInYear = getISOWeeksInYear; +proto.isoWeeksInISOWeekYear = getISOWeeksInISOWeekYear; +proto.date = getSetDayOfMonth; +proto.day = proto.days = getSetDayOfWeek; +proto.weekday = getSetLocaleDayOfWeek; +proto.isoWeekday = getSetISODayOfWeek; +proto.dayOfYear = getSetDayOfYear; +proto.hour = proto.hours = getSetHour; +proto.minute = proto.minutes = getSetMinute; +proto.second = proto.seconds = getSetSecond; +proto.millisecond = proto.milliseconds = getSetMillisecond; +proto.utcOffset = getSetOffset; +proto.utc = setOffsetToUTC; +proto.local = setOffsetToLocal; +proto.parseZone = setOffsetToParsedOffset; +proto.hasAlignedHourOffset = hasAlignedHourOffset; +proto.isDST = isDaylightSavingTime; +proto.isLocal = isLocal; +proto.isUtcOffset = isUtcOffset; +proto.isUtc = isUtc; +proto.isUTC = isUtc; +proto.zoneAbbr = getZoneAbbr; +proto.zoneName = getZoneName; +proto.dates = deprecate( + 'dates accessor is deprecated. Use date instead.', + getSetDayOfMonth +); +proto.months = deprecate( + 'months accessor is deprecated. Use month instead', + getSetMonth +); +proto.years = deprecate( + 'years accessor is deprecated. Use year instead', + getSetYear +); +proto.zone = deprecate( + 'moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/', + getSetZone +); +proto.isDSTShifted = deprecate( + 'isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information', + isDaylightSavingTimeShifted +); + +function createUnix(input) { + return createLocal(input * 1000); +} + +function createInZone() { + return createLocal.apply(null, arguments).parseZone(); +} + +function preParsePostFormat(string) { + return string; +} + +var proto$1 = Locale.prototype; + +proto$1.calendar = calendar; +proto$1.longDateFormat = longDateFormat; +proto$1.invalidDate = invalidDate; +proto$1.ordinal = ordinal; +proto$1.preparse = preParsePostFormat; +proto$1.postformat = preParsePostFormat; +proto$1.relativeTime = relativeTime; +proto$1.pastFuture = pastFuture; +proto$1.set = set; +proto$1.eras = localeEras; +proto$1.erasParse = localeErasParse; +proto$1.erasConvertYear = localeErasConvertYear; +proto$1.erasAbbrRegex = erasAbbrRegex; +proto$1.erasNameRegex = erasNameRegex; +proto$1.erasNarrowRegex = erasNarrowRegex; + +proto$1.months = localeMonths; +proto$1.monthsShort = localeMonthsShort; +proto$1.monthsParse = localeMonthsParse; +proto$1.monthsRegex = monthsRegex; +proto$1.monthsShortRegex = monthsShortRegex; +proto$1.week = localeWeek; +proto$1.firstDayOfYear = localeFirstDayOfYear; +proto$1.firstDayOfWeek = localeFirstDayOfWeek; + +proto$1.weekdays = localeWeekdays; +proto$1.weekdaysMin = localeWeekdaysMin; +proto$1.weekdaysShort = localeWeekdaysShort; +proto$1.weekdaysParse = localeWeekdaysParse; + +proto$1.weekdaysRegex = weekdaysRegex; +proto$1.weekdaysShortRegex = weekdaysShortRegex; +proto$1.weekdaysMinRegex = weekdaysMinRegex; + +proto$1.isPM = localeIsPM; +proto$1.meridiem = localeMeridiem; + +function get$1(format, index, field, setter) { + var locale = getLocale(), + utc = createUTC().set(setter, index); + return locale[field](utc, format); +} + +function listMonthsImpl(format, index, field) { + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + + if (index != null) { + return get$1(format, index, field, 'month'); + } + + var i, + out = []; + for (i = 0; i < 12; i++) { + out[i] = get$1(format, i, field, 'month'); + } + return out; +} + +// () +// (5) +// (fmt, 5) +// (fmt) +// (true) +// (true, 5) +// (true, fmt, 5) +// (true, fmt) +function listWeekdaysImpl(localeSorted, format, index, field) { + if (typeof localeSorted === 'boolean') { + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + } else { + format = localeSorted; + index = format; + localeSorted = false; + + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + } + + var locale = getLocale(), + shift = localeSorted ? locale._week.dow : 0, + i, + out = []; + + if (index != null) { + return get$1(format, (index + shift) % 7, field, 'day'); + } + + for (i = 0; i < 7; i++) { + out[i] = get$1(format, (i + shift) % 7, field, 'day'); + } + return out; +} + +function listMonths(format, index) { + return listMonthsImpl(format, index, 'months'); +} + +function listMonthsShort(format, index) { + return listMonthsImpl(format, index, 'monthsShort'); +} + +function listWeekdays(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdays'); +} + +function listWeekdaysShort(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdaysShort'); +} + +function listWeekdaysMin(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdaysMin'); +} + +getSetGlobalLocale('en', { + eras: [ + { + since: '0001-01-01', + until: +Infinity, + offset: 1, + name: 'Anno Domini', + narrow: 'AD', + abbr: 'AD', + }, + { + since: '0000-12-31', + until: -Infinity, + offset: 1, + name: 'Before Christ', + narrow: 'BC', + abbr: 'BC', + }, + ], + dayOfMonthOrdinalParse: /\d{1,2}(th|st|nd|rd)/, + ordinal: function (number) { + var b = number % 10, + output = + toInt((number % 100) / 10) === 1 + ? 'th' + : b === 1 + ? 'st' + : b === 2 + ? 'nd' + : b === 3 + ? 'rd' + : 'th'; + return number + output; + }, +}); + +// Side effect imports + +hooks.lang = deprecate( + 'moment.lang is deprecated. Use moment.locale instead.', + getSetGlobalLocale +); +hooks.langData = deprecate( + 'moment.langData is deprecated. Use moment.localeData instead.', + getLocale +); + +var mathAbs = Math.abs; + +function abs() { + var data = this._data; + + this._milliseconds = mathAbs(this._milliseconds); + this._days = mathAbs(this._days); + this._months = mathAbs(this._months); + + data.milliseconds = mathAbs(data.milliseconds); + data.seconds = mathAbs(data.seconds); + data.minutes = mathAbs(data.minutes); + data.hours = mathAbs(data.hours); + data.months = mathAbs(data.months); + data.years = mathAbs(data.years); + + return this; +} + +function addSubtract$1(duration, input, value, direction) { + var other = createDuration(input, value); + + duration._milliseconds += direction * other._milliseconds; + duration._days += direction * other._days; + duration._months += direction * other._months; + + return duration._bubble(); +} + +// supports only 2.0-style add(1, 's') or add(duration) +function add$1(input, value) { + return addSubtract$1(this, input, value, 1); +} + +// supports only 2.0-style subtract(1, 's') or subtract(duration) +function subtract$1(input, value) { + return addSubtract$1(this, input, value, -1); +} + +function absCeil(number) { + if (number < 0) { + return Math.floor(number); + } else { + return Math.ceil(number); + } +} + +function bubble() { + var milliseconds = this._milliseconds, + days = this._days, + months = this._months, + data = this._data, + seconds, + minutes, + hours, + years, + monthsFromDays; + + // if we have a mix of positive and negative values, bubble down first + // check: https://github.com/moment/moment/issues/2166 + if ( + !( + (milliseconds >= 0 && days >= 0 && months >= 0) || + (milliseconds <= 0 && days <= 0 && months <= 0) + ) + ) { + milliseconds += absCeil(monthsToDays(months) + days) * 864e5; + days = 0; + months = 0; + } + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + + seconds = absFloor(milliseconds / 1000); + data.seconds = seconds % 60; + + minutes = absFloor(seconds / 60); + data.minutes = minutes % 60; + + hours = absFloor(minutes / 60); + data.hours = hours % 24; + + days += absFloor(hours / 24); + + // convert days to months + monthsFromDays = absFloor(daysToMonths(days)); + months += monthsFromDays; + days -= absCeil(monthsToDays(monthsFromDays)); + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + data.days = days; + data.months = months; + data.years = years; + + return this; +} + +function daysToMonths(days) { + // 400 years have 146097 days (taking into account leap year rules) + // 400 years have 12 months === 4800 + return (days * 4800) / 146097; +} + +function monthsToDays(months) { + // the reverse of daysToMonths + return (months * 146097) / 4800; +} + +function as(units) { + if (!this.isValid()) { + return NaN; + } + var days, + months, + milliseconds = this._milliseconds; + + units = normalizeUnits(units); + + if (units === 'month' || units === 'quarter' || units === 'year') { + days = this._days + milliseconds / 864e5; + months = this._months + daysToMonths(days); + switch (units) { + case 'month': + return months; + case 'quarter': + return months / 3; + case 'year': + return months / 12; + } + } else { + // handle milliseconds separately because of floating point math errors (issue #1867) + days = this._days + Math.round(monthsToDays(this._months)); + switch (units) { + case 'week': + return days / 7 + milliseconds / 6048e5; + case 'day': + return days + milliseconds / 864e5; + case 'hour': + return days * 24 + milliseconds / 36e5; + case 'minute': + return days * 1440 + milliseconds / 6e4; + case 'second': + return days * 86400 + milliseconds / 1000; + // Math.floor prevents floating point math errors here + case 'millisecond': + return Math.floor(days * 864e5) + milliseconds; + default: + throw new Error('Unknown unit ' + units); + } + } +} + +function makeAs(alias) { + return function () { + return this.as(alias); + }; +} + +var asMilliseconds = makeAs('ms'), + asSeconds = makeAs('s'), + asMinutes = makeAs('m'), + asHours = makeAs('h'), + asDays = makeAs('d'), + asWeeks = makeAs('w'), + asMonths = makeAs('M'), + asQuarters = makeAs('Q'), + asYears = makeAs('y'), + valueOf$1 = asMilliseconds; + +function clone$1() { + return createDuration(this); +} + +function get$2(units) { + units = normalizeUnits(units); + return this.isValid() ? this[units + 's']() : NaN; +} + +function makeGetter(name) { + return function () { + return this.isValid() ? this._data[name] : NaN; + }; +} + +var milliseconds = makeGetter('milliseconds'), + seconds = makeGetter('seconds'), + minutes = makeGetter('minutes'), + hours = makeGetter('hours'), + days = makeGetter('days'), + months = makeGetter('months'), + years = makeGetter('years'); + +function weeks() { + return absFloor(this.days() / 7); +} + +var round = Math.round, + thresholds = { + ss: 44, // a few seconds to seconds + s: 45, // seconds to minute + m: 45, // minutes to hour + h: 22, // hours to day + d: 26, // days to month/week + w: null, // weeks to month + M: 11, // months to year + }; + +// helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize +function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { + return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); +} + +function relativeTime$1(posNegDuration, withoutSuffix, thresholds, locale) { + var duration = createDuration(posNegDuration).abs(), + seconds = round(duration.as('s')), + minutes = round(duration.as('m')), + hours = round(duration.as('h')), + days = round(duration.as('d')), + months = round(duration.as('M')), + weeks = round(duration.as('w')), + years = round(duration.as('y')), + a = + (seconds <= thresholds.ss && ['s', seconds]) || + (seconds < thresholds.s && ['ss', seconds]) || + (minutes <= 1 && ['m']) || + (minutes < thresholds.m && ['mm', minutes]) || + (hours <= 1 && ['h']) || + (hours < thresholds.h && ['hh', hours]) || + (days <= 1 && ['d']) || + (days < thresholds.d && ['dd', days]); + + if (thresholds.w != null) { + a = + a || + (weeks <= 1 && ['w']) || + (weeks < thresholds.w && ['ww', weeks]); + } + a = a || + (months <= 1 && ['M']) || + (months < thresholds.M && ['MM', months]) || + (years <= 1 && ['y']) || ['yy', years]; + + a[2] = withoutSuffix; + a[3] = +posNegDuration > 0; + a[4] = locale; + return substituteTimeAgo.apply(null, a); +} + +// This function allows you to set the rounding function for relative time strings +function getSetRelativeTimeRounding(roundingFunction) { + if (roundingFunction === undefined) { + return round; + } + if (typeof roundingFunction === 'function') { + round = roundingFunction; + return true; + } + return false; +} + +// This function allows you to set a threshold for relative time strings +function getSetRelativeTimeThreshold(threshold, limit) { + if (thresholds[threshold] === undefined) { + return false; + } + if (limit === undefined) { + return thresholds[threshold]; + } + thresholds[threshold] = limit; + if (threshold === 's') { + thresholds.ss = limit - 1; + } + return true; +} + +function humanize(argWithSuffix, argThresholds) { + if (!this.isValid()) { + return this.localeData().invalidDate(); + } + + var withSuffix = false, + th = thresholds, + locale, + output; + + if (typeof argWithSuffix === 'object') { + argThresholds = argWithSuffix; + argWithSuffix = false; + } + if (typeof argWithSuffix === 'boolean') { + withSuffix = argWithSuffix; + } + if (typeof argThresholds === 'object') { + th = Object.assign({}, thresholds, argThresholds); + if (argThresholds.s != null && argThresholds.ss == null) { + th.ss = argThresholds.s - 1; + } + } + + locale = this.localeData(); + output = relativeTime$1(this, !withSuffix, th, locale); + + if (withSuffix) { + output = locale.pastFuture(+this, output); + } + + return locale.postformat(output); +} + +var abs$1 = Math.abs; + +function sign(x) { + return (x > 0) - (x < 0) || +x; +} + +function toISOString$1() { + // for ISO strings we do not use the normal bubbling rules: + // * milliseconds bubble up until they become hours + // * days do not bubble at all + // * months bubble up until they become years + // This is because there is no context-free conversion between hours and days + // (think of clock changes) + // and also not between days and months (28-31 days per month) + if (!this.isValid()) { + return this.localeData().invalidDate(); + } + + var seconds = abs$1(this._milliseconds) / 1000, + days = abs$1(this._days), + months = abs$1(this._months), + minutes, + hours, + years, + s, + total = this.asSeconds(), + totalSign, + ymSign, + daysSign, + hmsSign; + + if (!total) { + // this is the same as C#'s (Noda) and python (isodate)... + // but not other JS (goog.date) + return 'P0D'; + } + + // 3600 seconds -> 60 minutes -> 1 hour + minutes = absFloor(seconds / 60); + hours = absFloor(minutes / 60); + seconds %= 60; + minutes %= 60; + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js + s = seconds ? seconds.toFixed(3).replace(/\.?0+$/, '') : ''; + + totalSign = total < 0 ? '-' : ''; + ymSign = sign(this._months) !== sign(total) ? '-' : ''; + daysSign = sign(this._days) !== sign(total) ? '-' : ''; + hmsSign = sign(this._milliseconds) !== sign(total) ? '-' : ''; + + return ( + totalSign + + 'P' + + (years ? ymSign + years + 'Y' : '') + + (months ? ymSign + months + 'M' : '') + + (days ? daysSign + days + 'D' : '') + + (hours || minutes || seconds ? 'T' : '') + + (hours ? hmsSign + hours + 'H' : '') + + (minutes ? hmsSign + minutes + 'M' : '') + + (seconds ? hmsSign + s + 'S' : '') + ); +} + +var proto$2 = Duration.prototype; + +proto$2.isValid = isValid$1; +proto$2.abs = abs; +proto$2.add = add$1; +proto$2.subtract = subtract$1; +proto$2.as = as; +proto$2.asMilliseconds = asMilliseconds; +proto$2.asSeconds = asSeconds; +proto$2.asMinutes = asMinutes; +proto$2.asHours = asHours; +proto$2.asDays = asDays; +proto$2.asWeeks = asWeeks; +proto$2.asMonths = asMonths; +proto$2.asQuarters = asQuarters; +proto$2.asYears = asYears; +proto$2.valueOf = valueOf$1; +proto$2._bubble = bubble; +proto$2.clone = clone$1; +proto$2.get = get$2; +proto$2.milliseconds = milliseconds; +proto$2.seconds = seconds; +proto$2.minutes = minutes; +proto$2.hours = hours; +proto$2.days = days; +proto$2.weeks = weeks; +proto$2.months = months; +proto$2.years = years; +proto$2.humanize = humanize; +proto$2.toISOString = toISOString$1; +proto$2.toString = toISOString$1; +proto$2.toJSON = toISOString$1; +proto$2.locale = locale; +proto$2.localeData = localeData; + +proto$2.toIsoString = deprecate( + 'toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', + toISOString$1 +); +proto$2.lang = lang; + +// FORMATTING + +addFormatToken('X', 0, 0, 'unix'); +addFormatToken('x', 0, 0, 'valueOf'); + +// PARSING + +addRegexToken('x', matchSigned); +addRegexToken('X', matchTimestamp); +addParseToken('X', function (input, array, config) { + config._d = new Date(parseFloat(input) * 1000); +}); +addParseToken('x', function (input, array, config) { + config._d = new Date(toInt(input)); +}); + +//! moment.js + +hooks.version = '2.30.1'; + +setHookCallback(createLocal); + +hooks.fn = proto; +hooks.min = min; +hooks.max = max; +hooks.now = now; +hooks.utc = createUTC; +hooks.unix = createUnix; +hooks.months = listMonths; +hooks.isDate = isDate; +hooks.locale = getSetGlobalLocale; +hooks.invalid = createInvalid; +hooks.duration = createDuration; +hooks.isMoment = isMoment; +hooks.weekdays = listWeekdays; +hooks.parseZone = createInZone; +hooks.localeData = getLocale; +hooks.isDuration = isDuration; +hooks.monthsShort = listMonthsShort; +hooks.weekdaysMin = listWeekdaysMin; +hooks.defineLocale = defineLocale; +hooks.updateLocale = updateLocale; +hooks.locales = listLocales; +hooks.weekdaysShort = listWeekdaysShort; +hooks.normalizeUnits = normalizeUnits; +hooks.relativeTimeRounding = getSetRelativeTimeRounding; +hooks.relativeTimeThreshold = getSetRelativeTimeThreshold; +hooks.calendarFormat = getCalendarFormat; +hooks.prototype = proto; + +// currently HTML5 input type only supports 24-hour formats +hooks.HTML5_FMT = { + DATETIME_LOCAL: 'YYYY-MM-DDTHH:mm', // + DATETIME_LOCAL_SECONDS: 'YYYY-MM-DDTHH:mm:ss', // + DATETIME_LOCAL_MS: 'YYYY-MM-DDTHH:mm:ss.SSS', // + DATE: 'YYYY-MM-DD', // + TIME: 'HH:mm', // + TIME_SECONDS: 'HH:mm:ss', // + TIME_MS: 'HH:mm:ss.SSS', // + WEEK: 'GGGG-[W]WW', // + MONTH: 'YYYY-MM', // +}; + +export default hooks; diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 54d98d6b..57a5dd15 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -8,7 +8,7 @@ ReactPy - {% pyscript_setup %} + {% pyscript_setup extra_js='{"/static/moment.js":"moment"}' config="{}" %} @@ -26,6 +26,8 @@

ReactPy PyScript Test Page


{% component "test_app.pyscript.components.server_side.parent_toggle" %}
+ {% pyscript_component "./test_app/pyscript/components/remote_js_module.py" %} +
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 1af5d09f..11fdc390 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -719,5 +719,6 @@ def test_pyscript_components(self): new_page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) new_page.wait_for_selector("#parent-toggle pre[data-value='1']") + new_page.wait_for_selector("#moment[data-success=true]") finally: new_page.close() From cb7e279aa52632b8def127de74c9d9e4612096ea Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jun 2024 04:37:24 -0700 Subject: [PATCH 27/28] self review --- CHANGELOG.md | 8 ++++---- docs/src/reference/router.md | 2 +- docs/src/reference/template-tag.md | 12 +++++++----- src/reactpy_django/components.py | 5 ++--- src/reactpy_django/pyscript/component_template.py | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb3cc59..1e1e5b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,10 +37,10 @@ Using the following categories, list your changes in this order: ### Added - Client-side Python components can now be rendered via the new `{% pyscript_component %}` template tag -- PyScript's can be made accessible for an existing page using the `{% pyscript_setup %}` template tag - - This tag can also be used to load additional dependencies, or change the default PyScript configuration. + - You must first call the `{% pyscript_setup %}` template tag to load PyScript dependencies - Client-side components can be embedded into existing server-side components via `reactpy_django.components.pyscript_component`. -- You can now write Python code that runs within client browser via the `reactpy_django.html.pyscript` element. This is a viable substitution for most JavaScript code. +- Tired of writing JavaScript? You can now write PyScript code that runs directly within client browser via the `reactpy_django.html.pyscript` element. + - This is a viable substitution for most JavaScript code. ### Changed @@ -56,7 +56,7 @@ Using the following categories, list your changes in this order: ### Removed -- `QueryOptions` and `MutationOptions` have been removed. Their values are now passed direct into the hook. +- `QueryOptions` and `MutationOptions` have been removed. The value contained within these objects are now passed directly into the hook. ### Fixed diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 5efe34a0..66ad0f9e 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -20,7 +20,7 @@ URL router that enables the ability to conditionally render other components bas !!! warning "Pitfall" - All pages where `django_router` is used must have the same, or more permissive URL exposure within Django's [URL patterns](https://docs.djangoproject.com/en/5.0/topics/http/urls/#example). You can think of this component as a secondary, client-side router. Django still handles the primary server-side routes. + All pages where `django_router` is used must have identical, or more permissive URL exposure within Django's [URL patterns](https://docs.djangoproject.com/en/5.0/topics/http/urls/#example). You can think of the router component as a secondary, client-side router. Django still handles the primary server-side routes. We recommend creating a route with a wildcard `.*` to forward routes to ReactPy. For example... `#!python re_path(r"^/router/.*$", my_reactpy_view)` diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index b6070fd8..2062a824 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -225,11 +225,13 @@ Your PyScript component file requires a `#!python def root()` component to funct ??? question "Does my entire component need to be contained in one file?" - PyScript components do not have access to your local disk, and thus cannot `#!python import` any local Python modules. + Splitting a large file into multiple files is a common practice in software development. - To bypass this, you can declare multiple file paths. These files will automatically combined during processing. + However, PyScript components are run on the client browser. As such, they do not have access to your local development environment, and thus cannot `#!python import` any local Python files. - Here is how we recommend doing that while retaining type hints. + If your PyScript component file gets too large, you can declare multiple file paths instead. These files will automatically combined by ReactPy. + + Here is how we recommend splitting your component into multiple files while avoiding local imports but retaining type hints. @@ -295,9 +297,9 @@ Your PyScript component file requires a `#!python def root()` component to funct ## PyScript Setup -This template tag configures the current page to be able to run `pyscript` by loading PyScript's static files. +This template tag configures the current page to be able to run `pyscript`. -You can optionally include a list of Python packages to install within the PyScript environment, or a [PyScript configuration dictionary](https://docs.pyscript.net/2024.6.1/user-guide/configuration/). +You can optionally use this tag to configure the current PyScript enviroment. For example, you can include a list of Python packages to automatically install within the PyScript environment. === "my_template.html" diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 3d72c596..579c73e3 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -327,9 +327,8 @@ def _pyscript_component( executor = render_pyscript_template(file_paths, uuid, root) if not rendered: - # FIXME: This is needed to properly re-render PyScript such as - # during a WebSocket disconnection / reconnection. - # There may be a better way to do this in the future. + # FIXME: This is needed to properly re-render PyScript during a WebSocket + # disconnection / reconnection. There may be a better way to do this in the future. set_rendered(True) return None diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index e2288ccf..59442571 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -14,7 +14,7 @@ def user_workspace_UUID(): This code is designed to be run directly by PyScript, and is not intended to be run in a normal Python environment. - ReactPy-Django's template tag performs string substitutions to turn this file into valid PyScript. + ReactPy-Django performs string substitutions to turn this file into valid PyScript. """ def root(): ... From 416927a802a20011a0777c607f26e2d77c66bacd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jun 2024 04:38:25 -0700 Subject: [PATCH 28/28] fix typo --- docs/src/reference/template-tag.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 2062a824..434c81d0 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -299,7 +299,7 @@ Your PyScript component file requires a `#!python def root()` component to funct This template tag configures the current page to be able to run `pyscript`. -You can optionally use this tag to configure the current PyScript enviroment. For example, you can include a list of Python packages to automatically install within the PyScript environment. +You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment. === "my_template.html"