From a55b453d5ab5ef98ac7d25b4f7e196d0ae7cf87b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 13 Jun 2021 00:28:28 -0700 Subject: [PATCH 01/20] remove all runtime usages of NPM With recent changes to the custom component interface, it's now possible to remove all runtime reliance on NPM. Doing so has many virtuous knock-on effects: 1. Removal of large chunks of code 2. Greatly simplifies how users dynamically experiment with React component libraries, because their usage no longer requires a build step. Instead they can be loaded in the browser from a CDN that distributes ESM modules. 3. The built-in client code needs to make fewer assumption about where static resources are located, and as a result, it's also easier to coordinate the server and client code. 4. Alternate client implementations benefit from this simplicity. Now, it's possible to install idom-client-react normally and write a loadImportSource() function that looks for route serving the contents of IDOM_WEB_MODULES_DIR. This change includes large breaking changes: - The CLI is being removed as it won't be needed any longer - The idom.client is being removed in favor of a stripped down idom.web module - The IDOM_CLIENT_BUILD_DIR config option will no longer exist and a new IDOM_WEB_MODULES_DIR which only contains dynamically linked web modules. While this new directory's location is configurable, it is meant to be transient and should not be re-used across sessions. The new idom.web module takes a simpler approach to constructing import sources and expands upon the logic for resolving imports by allowing exports from URLs to be discovered too. Now, that IDOM isn't using NPM to dynamically install component libraries idom.web instead creates JS modules from template files and links them into IDOM_WEB_MODULES_DIR. These templates ultimately direct the browser to load the desired library from a CDN. --- MANIFEST.in | 4 +- docs/Dockerfile | 4 +- .../examples/material_ui_button_no_action.py | 7 +- .../examples/material_ui_button_on_click.py | 5 +- docs/source/examples/material_ui_slider.py | 5 +- docs/source/examples/super_simple_chart.py | 9 +- docs/source/examples/victory_chart.py | 6 +- docs/source/installation.rst | 92 ------ noxfile.py | 4 - requirements/check-types.txt | 1 + requirements/pkg-deps.txt | 6 +- requirements/pkg-extras.txt | 5 - setup.py | 12 +- src/idom/__init__.py | 21 +- src/idom/__main__.py | 5 - src/idom/_option.py | 5 - src/idom/cli.py | 56 ---- src/idom/client/{app => }/.gitignore | 0 src/idom/client/{app => }/README.md | 0 src/idom/client/_private.py | 104 ------- .../idom-app-react/src/user-packages.js | 1 - .../{app => }/idom-logo-square-small.svg | 0 src/idom/client/{app => }/index.html | 0 src/idom/client/manage.py | 219 -------------- src/idom/client/module.py | 230 --------------- src/idom/client/{app => }/package-lock.json | 0 src/idom/client/{app => }/package.json | 0 .../packages/idom-app-react/package.json | 0 .../packages/idom-app-react/src/index.js | 11 +- .../packages/idom-client-react/.gitignore | 0 .../packages/idom-client-react/README.md | 0 .../idom-client-react/package-lock.json | 0 .../packages/idom-client-react/package.json | 0 .../idom-client-react/src/component.js | 0 .../idom-client-react/src/event-to-object.js | 0 .../packages/idom-client-react/src/index.js | 0 .../packages/idom-client-react/src/mount.js | 0 .../packages/idom-client-react/src/utils.js | 0 .../tests/event-to-object.test.js | 0 .../idom-client-react/tests/tooling/dom.js | 0 .../idom-client-react/tests/tooling/setup.js | 0 src/idom/client/{app => }/snowpack.config.js | 0 src/idom/config.py | 27 +- src/idom/dialect.py | 174 ----------- src/idom/server/fastapi.py | 15 +- src/idom/server/flask.py | 14 +- src/idom/server/sanic.py | 7 +- src/idom/server/tornado.py | 13 +- src/idom/server/utils.py | 6 +- src/idom/testing.py | 4 +- src/idom/web/__init__.py | 9 + src/idom/web/module.py | 174 +++++++++++ src/idom/web/templates/preact.js | 12 + src/idom/web/templates/react.js | 8 + src/idom/web/utils.py | 96 +++++++ tests/conftest.py | 20 +- tests/{test_client => }/test_app.py | 32 --- tests/test_cli.py | 37 --- tests/test_client/__init__.py | 0 tests/test_client/test__private.py | 43 --- tests/test_client/test_manage.py | 151 ---------- tests/test_client/test_module.py | 116 -------- tests/test_client/utils.py | 10 - tests/test_dialect.py | 269 ------------------ .../test_common/test_per_client_state.py | 7 +- .../client => tests/test_web}/__init__.py | 0 .../set-flag-when-unmount-is-called.js | 0 .../js_fixtures}/simple-button.js | 0 tests/test_web/test_module.py | 39 +++ tests/test_web/test_utils.py | 38 +++ 70 files changed, 456 insertions(+), 1677 deletions(-) delete mode 100644 src/idom/__main__.py delete mode 100644 src/idom/cli.py rename src/idom/client/{app => }/.gitignore (100%) rename src/idom/client/{app => }/README.md (100%) delete mode 100644 src/idom/client/_private.py delete mode 100644 src/idom/client/app/packages/idom-app-react/src/user-packages.js rename src/idom/client/{app => }/idom-logo-square-small.svg (100%) rename src/idom/client/{app => }/index.html (100%) delete mode 100644 src/idom/client/manage.py delete mode 100644 src/idom/client/module.py rename src/idom/client/{app => }/package-lock.json (100%) rename src/idom/client/{app => }/package.json (100%) rename src/idom/client/{app => }/packages/idom-app-react/package.json (100%) rename src/idom/client/{app => }/packages/idom-app-react/src/index.js (78%) rename src/idom/client/{app => }/packages/idom-client-react/.gitignore (100%) rename src/idom/client/{app => }/packages/idom-client-react/README.md (100%) rename src/idom/client/{app => }/packages/idom-client-react/package-lock.json (100%) rename src/idom/client/{app => }/packages/idom-client-react/package.json (100%) rename src/idom/client/{app => }/packages/idom-client-react/src/component.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/src/event-to-object.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/src/index.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/src/mount.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/src/utils.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/tests/event-to-object.test.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/tests/tooling/dom.js (100%) rename src/idom/client/{app => }/packages/idom-client-react/tests/tooling/setup.js (100%) rename src/idom/client/{app => }/snowpack.config.js (100%) delete mode 100644 src/idom/dialect.py create mode 100644 src/idom/web/__init__.py create mode 100644 src/idom/web/module.py create mode 100644 src/idom/web/templates/preact.js create mode 100644 src/idom/web/templates/react.js create mode 100644 src/idom/web/utils.py rename tests/{test_client => }/test_app.py (59%) delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_client/__init__.py delete mode 100644 tests/test_client/test__private.py delete mode 100644 tests/test_client/test_manage.py delete mode 100644 tests/test_client/test_module.py delete mode 100644 tests/test_client/utils.py delete mode 100644 tests/test_dialect.py rename {src/idom/client => tests/test_web}/__init__.py (100%) rename tests/{test_client/js => test_web/js_fixtures}/set-flag-when-unmount-is-called.js (100%) rename tests/{test_client/js => test_web/js_fixtures}/simple-button.js (100%) create mode 100644 tests/test_web/test_module.py create mode 100644 tests/test_web/test_utils.py diff --git a/MANIFEST.in b/MANIFEST.in index 853a30e12..d780ab7a3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,4 @@ recursive-include src/idom * -recursive-exclude src/idom/client/app/node_modules * -recursive-exclude src/idom/client/app/web_modules * -recursive-exclude src/idom/client/build * +recursive-exclude src/idom/client/node_modules * include requirements/prod.txt include requirements/extras.txt diff --git a/docs/Dockerfile b/docs/Dockerfile index 252732b18..27ebad0ff 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -28,9 +28,6 @@ ADD README.md ./ RUN pip install -e .[all] -ENV IDOM_DEBUG_MODE=1 -ENV IDOM_CLIENT_BUILD_DIR=./build - RUN python -m idom install htm victory semantic-ui-react @material-ui/core # Build the Docs @@ -44,4 +41,5 @@ RUN sphinx-build -b html docs/source docs/build # Define Entrypoint # ----------------- ENV PORT 5000 +ENV IDOM_DEBUG_MODE=1 CMD python docs/main.py diff --git a/docs/source/examples/material_ui_button_no_action.py b/docs/source/examples/material_ui_button_no_action.py index 932efd28f..19300792b 100644 --- a/docs/source/examples/material_ui_button_no_action.py +++ b/docs/source/examples/material_ui_button_no_action.py @@ -1,12 +1,11 @@ import idom -material_ui = idom.install("@material-ui/core", fallback="loading...") +mui = idom.web.module_from_template("react", "@material-ui/core", fallback="⌛") +Button = idom.web.export(mui, "Button") idom.run( idom.component( - lambda: material_ui.Button( - {"color": "primary", "variant": "contained"}, "Hello World!" - ) + lambda: Button({"color": "primary", "variant": "contained"}, "Hello World!") ) ) diff --git a/docs/source/examples/material_ui_button_on_click.py b/docs/source/examples/material_ui_button_on_click.py index 3b0ec5a88..bde5b5d90 100644 --- a/docs/source/examples/material_ui_button_on_click.py +++ b/docs/source/examples/material_ui_button_on_click.py @@ -3,7 +3,8 @@ import idom -material_ui = idom.install("@material-ui/core", fallback="loading...") +mui = idom.web.module_from_template("react", "@material-ui/core", fallback="⌛") +Button = idom.web.export(mui, "Button") @idom.component @@ -11,7 +12,7 @@ def ViewButtonEvents(): event, set_event = idom.hooks.use_state(None) return idom.html.div( - material_ui.Button( + Button( { "color": "primary", "variant": "contained", diff --git a/docs/source/examples/material_ui_slider.py b/docs/source/examples/material_ui_slider.py index ddffcccb7..12f0f080e 100644 --- a/docs/source/examples/material_ui_slider.py +++ b/docs/source/examples/material_ui_slider.py @@ -3,7 +3,8 @@ import idom -material_ui = idom.install("@material-ui/core", fallback="loading...") +mui = idom.web.module_from_template("react", "@material-ui/core", fallback="⌛") +Slider = idom.web.export(mui, "Slider") @idom.component @@ -11,7 +12,7 @@ def ViewSliderEvents(): (event, value), set_data = idom.hooks.use_state((None, 50)) return idom.html.div( - material_ui.Slider( + Slider( { "color": "primary" if value < 50 else "secondary", "step": 10, diff --git a/docs/source/examples/super_simple_chart.py b/docs/source/examples/super_simple_chart.py index 6f210b98d..ad3a748d3 100644 --- a/docs/source/examples/super_simple_chart.py +++ b/docs/source/examples/super_simple_chart.py @@ -1,15 +1,16 @@ from pathlib import Path import idom +from idom.config import IDOM_WED_MODULES_DIR -path_to_source_file = Path(__file__).parent / "super_simple_chart.js" -ssc = idom.Module("super-simple-chart", source_file=path_to_source_file) - +file = Path(__file__).parent / "super_simple_chart.js" +ssc = idom.web.module_from_file("super-simple-chart", file, fallback="⌛") +SuperSimpleChart = idom.web.export(ssc, "SuperSimpleChart") idom.run( idom.component( - lambda: ssc.SuperSimpleChart( + lambda: SuperSimpleChart( { "data": [ {"x": 1, "y": 2}, diff --git a/docs/source/examples/victory_chart.py b/docs/source/examples/victory_chart.py index 45c3dbb71..cdfad5e22 100644 --- a/docs/source/examples/victory_chart.py +++ b/docs/source/examples/victory_chart.py @@ -1,6 +1,8 @@ import idom -victory = idom.install("victory", fallback="loading...") +victory = idom.web.module_from_template("react", "victory", fallback="⌛") +VictoryBar = idom.web.export(victory, "VictoryBar") + bar_style = {"parent": {"width": "500px"}, "data": {"fill": "royalblue"}} -idom.run(idom.component(lambda: victory.VictoryBar({"style": bar_style}))) +idom.run(idom.component(lambda: VictoryBar({"style": bar_style}))) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index a4c46318f..2b1ab6f2a 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -55,97 +55,5 @@ table below: * - ``flask`` - `Flask `__ as a :ref:`Layout Server` - * - ``dialect`` - - :ref:`Python Language Extension` for writing JSX-like syntax - * - ``all`` - All the features listed above (not usually needed) - - -Python Language Extension -------------------------- - -IDOM includes an import-time transpiler for writing JSX-like syntax in a ``.py`` file! - -.. code-block:: python - - # dialect=html - from idom import html - - message = "hello world!" - attrs = {"height": "10px", "width": "10px"} - model = html(f"

{message}

") - - assert model == { - "tagName": "div", - "attributes": {"height": "10px", "width": "10px"}, - "children": [{"tagName": "p", "children": ["hello world!"]}], - } - -With Jupyter and IPython support: - -.. code-block:: python - - %%dialect html - from idom import html - assert html(f"
") == {"tagName": "div"} - -That you can install with ``pip``: - -.. code-block:: - - pip install idom[dialect] - - -Usage -..... - -1. Import ``idom`` in your application's ``entrypoint.py`` - -2. Import ``your_module.py`` with a ``# dialect=html`` header comment. - -3. Inside ``your_module.py`` import ``html`` from ``idom`` - -4. Run ``python entrypoint.py`` from your console. - -So here's the files you should have set up: - -.. code-block:: text - - project - |- entrypoint.py - |- your_module.py - -The contents of ``entrypoint.py`` should contain: - -.. code-block:: - - import idom # this needs to be first! - import your_module - -While ``your_module.py`` should contain the following: - -.. code-block:: - - # dialect=html - from idom import html - assert html(f"
") == {"tagName": "div"} - -And that's it! - - -How It Works -............ - -Once ``idom`` has been imported at your application's entrypoint, any following modules -imported with a ``# dialect=html`` header comment get transpiled just before they're -executed. This is accomplished by using Pyalect_ to hook a transpiler into Pythons -import system. The :class:`~idom.dialect.HtmlDialectTranspiler` implements Pyalect_'s -:class:`~pyalect.dialect.Transpiler` interface using some tooling from htm.py_. - - -.. Links -.. ===== - -.. _Pyalect: https://pyalect.readthedocs.io/en/latest/ -.. _htm.py: https://github.com/jviide/htm.py diff --git a/noxfile.py b/noxfile.py index 8742367fc..d77a6779a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -205,7 +205,3 @@ def install_idom_dev(session: Session, extras: str = "stable") -> None: session.install("-e", f".[{extras}]") else: session.posargs.remove("--no-install") - if "--no-restore" not in session.posargs: - session.run("idom", "restore") - else: - session.posargs.remove("--no-restore") diff --git a/requirements/check-types.txt b/requirements/check-types.txt index b036bcd49..2ae26d60a 100644 --- a/requirements/check-types.txt +++ b/requirements/check-types.txt @@ -3,3 +3,4 @@ types-click types-tornado types-pkg-resources types-flask +types-requests diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 75db38e0e..0ef2d1c96 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,10 +1,6 @@ typing-extensions >=3.7.4 mypy-extensions >=0.4.3 anyio >=3.0 -async_exit_stack >=1.0.1; python_version<"3.7" jsonpatch >=1.26 -typer >=0.3.2 -click-spinner >=0.1.10 fastjsonschema >=2.14.5 -rich >=9.13.0 -appdirs >=1.4.4 +requests >=2.0 diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index c1fbd225c..6205adf00 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -19,8 +19,3 @@ selenium # extra=matplotlib matplotlib - -# extra=dialect -htm -pyalect -tagged diff --git a/setup.py b/setup.py index 469b1acc7..c8cddf9e8 100644 --- a/setup.py +++ b/setup.py @@ -63,16 +63,6 @@ def list2cmdline(cmd_list): } -# ----------------------------------------------------------------------------- -# CLI Entrypoints -# ----------------------------------------------------------------------------- - - -package["entry_points"] = { - "console_scripts": ["idom = idom.__main__:main"], -} - - # ----------------------------------------------------------------------------- # Requirements # ----------------------------------------------------------------------------- @@ -129,7 +119,7 @@ class Command(cls): def run(self): log.info("Installing Javascript...") try: - js_dir = str(package_dir / "client" / "app") + js_dir = str(package_dir / "client") npm = shutil.which("npm") # this is required on windows if npm is None: raise RuntimeError("NPM is not installed.") diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 21fed6f1a..8074adff1 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -10,8 +10,7 @@ __author__ = "idom-team" -from . import config, log -from .client.module import Import, Module, install +from . import config, log, web from .core import hooks from .core.component import Component, component from .core.events import Events, event @@ -23,21 +22,6 @@ from .widgets.utils import hotswap, multiview -# try to automatically setup the dialect's import hook -try: - import htm - import pyalect - import tagged -except ImportError: # pragma: no cover - pass -else: - from . import dialect - - del pyalect - del tagged - del htm - - __all__ = [ "run", "component", @@ -51,8 +35,6 @@ "server", "Ref", "vdom", - "Module", - "Import", "hotswap", "multiview", "html_to_vdom", @@ -62,4 +44,5 @@ "install", "log", "config", + "web", ] diff --git a/src/idom/__main__.py b/src/idom/__main__.py deleted file mode 100644 index 2f05ddc22..000000000 --- a/src/idom/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .cli import main - - -if __name__ == "__main__": - main() diff --git a/src/idom/_option.py b/src/idom/_option.py index 2ee46c941..ef18e02c0 100644 --- a/src/idom/_option.py +++ b/src/idom/_option.py @@ -8,16 +8,12 @@ import os from logging import getLogger from typing import Any, Callable, Generic, TypeVar, cast -from weakref import WeakSet _O = TypeVar("_O") logger = getLogger(__name__) -ALL_OPTIONS: WeakSet[Option[Any]] = WeakSet() - - class Option(Generic[_O]): """An option that can be set using an environment variable of the same name""" @@ -35,7 +31,6 @@ def __init__( if name in os.environ: self._current = validator(os.environ[name]) logger.debug(f"{self._name}={self.current}") - ALL_OPTIONS.add(self) @property def name(self) -> str: diff --git a/src/idom/cli.py b/src/idom/cli.py deleted file mode 100644 index 00701c788..000000000 --- a/src/idom/cli.py +++ /dev/null @@ -1,56 +0,0 @@ -from logging.config import dictConfig -from typing import List - -import typer - -import idom -from idom.client import manage as manage_client - -from .config import all_options -from .log import logging_config_defaults - - -main = typer.Typer() - - -@main.callback(invoke_without_command=True, no_args_is_help=True) -def root( - version: bool = typer.Option( - False, - "--version", - help="show the current version", - show_default=False, - is_eager=True, - ) -) -> None: - """Command line interface for IDOM""" - - # reset logging config after Typer() has wrapped stdout - dictConfig(logging_config_defaults()) - - if version: - typer.echo(idom.__version__) - - return None - - -@main.command() -def install(packages: List[str]) -> None: - """Install a Javascript package from NPM into the client""" - manage_client.build(packages, clean_build=False) - return None - - -@main.command() -def restore() -> None: - """Return to a fresh install of the client""" - manage_client.restore() - return None - - -@main.command() -def options() -> None: - """Show available global options and their current values""" - for opt in list(sorted(all_options(), key=lambda opt: opt.name)): - name = typer.style(opt.name, bold=True) - typer.echo(f"{name} = {opt.current}") diff --git a/src/idom/client/app/.gitignore b/src/idom/client/.gitignore similarity index 100% rename from src/idom/client/app/.gitignore rename to src/idom/client/.gitignore diff --git a/src/idom/client/app/README.md b/src/idom/client/README.md similarity index 100% rename from src/idom/client/app/README.md rename to src/idom/client/README.md diff --git a/src/idom/client/_private.py b/src/idom/client/_private.py deleted file mode 100644 index 8ea00f890..000000000 --- a/src/idom/client/_private.py +++ /dev/null @@ -1,104 +0,0 @@ -import json -import logging -import re -import shutil -from os.path import getmtime -from pathlib import Path -from typing import Dict, Set, Tuple, cast - -from idom.config import IDOM_CLIENT_BUILD_DIR - - -logger = logging.getLogger(__name__) - -HERE = Path(__file__).parent -APP_DIR = HERE / "app" -BACKUP_BUILD_DIR = APP_DIR / "build" - -# the path relative to the build that contains import sources -IDOM_CLIENT_IMPORT_SOURCE_INFIX = "_snowpack/pkg" - - -def _run_build_dir_init_only_once() -> None: # pragma: no cover - """Initialize the runtime build directory - this should only be called once""" - if not IDOM_CLIENT_BUILD_DIR.current.exists(): - logger.debug("creating new runtime build directory") - IDOM_CLIENT_BUILD_DIR.current.parent.mkdir(parents=True, exist_ok=True) - # populate the runtime build directory if it doesn't exist - shutil.copytree(BACKUP_BUILD_DIR, IDOM_CLIENT_BUILD_DIR.current, symlinks=True) - elif getmtime(BACKUP_BUILD_DIR) > getmtime(IDOM_CLIENT_BUILD_DIR.current): - logger.debug("updating runtime build directory because it is out of date") - # delete the existing runtime build because it's out of date - shutil.rmtree(IDOM_CLIENT_BUILD_DIR.current) - # replace it with the newer backup build (presumable from a fresh install) - shutil.copytree(BACKUP_BUILD_DIR, IDOM_CLIENT_BUILD_DIR.current, symlinks=True) - else: - logger.debug("runtime build directory is up to date") - - -_run_build_dir_init_only_once() # this is only ever called once at runtime! - - -def get_user_packages_file(app_dir: Path) -> Path: - return app_dir / "packages" / "idom-app-react" / "src" / "user-packages.js" - - -def restore_build_dir_from_backup() -> None: - target = IDOM_CLIENT_BUILD_DIR.current - if target.exists(): - shutil.rmtree(target) - shutil.copytree(BACKUP_BUILD_DIR, target, symlinks=True) - - -def replace_build_dir(source: Path) -> None: - target = IDOM_CLIENT_BUILD_DIR.current - if target.exists(): - shutil.rmtree(target) - shutil.copytree(source, target, symlinks=True) - - -def get_package_name(pkg: str) -> str: - return split_package_name_and_version(pkg)[0] - - -def split_package_name_and_version(pkg: str) -> Tuple[str, str]: - at_count = pkg.count("@") - if pkg.startswith("@"): - if at_count == 1: - return pkg, "" - else: - name, version = pkg[1:].split("@", 1) - return ("@" + name), version - elif at_count: - name, version = pkg.split("@", 1) - return name, version - else: - return pkg, "" - - -def build_dependencies() -> Dict[str, str]: - package_json = IDOM_CLIENT_BUILD_DIR.current / "package.json" - return cast(Dict[str, str], json.loads(package_json.read_text())["dependencies"]) - - -_JS_MODULE_EXPORT_PATTERN = re.compile( - r";?\s*export\s*{([0-9a-zA-Z_$\s,]*)}\s*;", re.MULTILINE -) -_JS_VAR = r"[a-zA-Z_$][0-9a-zA-Z_$]*" -_JS_MODULE_EXPORT_NAME_PATTERN = re.compile( - fr";?\s*export\s+({_JS_VAR})\s+{_JS_VAR}\s*;", re.MULTILINE -) -_JS_MODULE_EXPORT_FUNC_PATTERN = re.compile( - fr";?\s*export\s+function\s+({_JS_VAR})\s*\(.*?", re.MULTILINE -) - - -def find_js_module_exports_in_source(content: str) -> Set[str]: - names: Set[str] = set() - for match in _JS_MODULE_EXPORT_PATTERN.findall(content): - for export in match.split(","): - export_parts = export.split(" as ", 1) - names.add(export_parts[-1].strip()) - names.update(_JS_MODULE_EXPORT_FUNC_PATTERN.findall(content)) - names.update(_JS_MODULE_EXPORT_NAME_PATTERN.findall(content)) - return names diff --git a/src/idom/client/app/packages/idom-app-react/src/user-packages.js b/src/idom/client/app/packages/idom-app-react/src/user-packages.js deleted file mode 100644 index ff8b4c563..000000000 --- a/src/idom/client/app/packages/idom-app-react/src/user-packages.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/src/idom/client/app/idom-logo-square-small.svg b/src/idom/client/idom-logo-square-small.svg similarity index 100% rename from src/idom/client/app/idom-logo-square-small.svg rename to src/idom/client/idom-logo-square-small.svg diff --git a/src/idom/client/app/index.html b/src/idom/client/index.html similarity index 100% rename from src/idom/client/app/index.html rename to src/idom/client/index.html diff --git a/src/idom/client/manage.py b/src/idom/client/manage.py deleted file mode 100644 index 573568e98..000000000 --- a/src/idom/client/manage.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Client Manager -============== -""" - -import shutil -import subprocess -import sys -from logging import getLogger -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Dict, Iterable, List, Sequence, Set, Union - -from idom.config import IDOM_CLIENT_BUILD_DIR - -from . import _private - - -logger = getLogger(__name__) - - -def web_modules_dir() -> Path: - """The directory containing all web modules - - .. warning:: - - No assumptions should be made about the exact structure of this directory! - """ - return IDOM_CLIENT_BUILD_DIR.current / "_snowpack" / "pkg" - - -def web_module_path(package_name: str, must_exist: bool = False) -> Path: - """Get the :class:`Path` to a web module's source""" - path = web_modules_dir().joinpath(*(package_name + ".js").split("/")) - if must_exist and not path.exists(): - raise ValueError( - f"Web module {package_name!r} does not exist at path {str(path)!r}" - ) - return path - - -def web_module_exports(package_name: str) -> Set[str]: - """Get a list of names this module exports""" - web_module_path(package_name, must_exist=True) - return _private.find_js_module_exports_in_source( - web_module_path(package_name).read_text(encoding="utf-8") - ) - - -def web_module_exists(package_name: str) -> bool: - """Whether a web module with a given name exists""" - return web_module_path(package_name).exists() - - -def web_module_names() -> Set[str]: - """Get the names of all installed web modules""" - names = [] - web_mod_dir = web_modules_dir() - for pth in web_mod_dir.glob("**/*.js"): - rel_pth = pth.relative_to(web_mod_dir) - if Path("common") in rel_pth.parents: - continue - module_path = str(rel_pth.as_posix()) - if module_path.endswith(".js"): - module_path = module_path[:-3] - names.append(module_path) - return set(names) - - -def add_web_module( - package_name: str, - source: Union[Path, str], -) -> None: - """Add a web module from source""" - resolved_source = Path(source).resolve() - if not resolved_source.exists(): - raise FileNotFoundError(f"Package source file does not exist: {str(source)!r}") - target = web_module_path(package_name) - if target.resolve() == resolved_source: - return None # already added - target.parent.mkdir(parents=True, exist_ok=True) - # this will raise an error if already exists - target.symlink_to(resolved_source) - - -def remove_web_module(package_name: str, must_exist: bool = False) -> None: - """Remove a web module""" - web_module_path(package_name, must_exist).unlink() - - -def restore() -> None: - _private.restore_build_dir_from_backup() - - -def build( - packages: Sequence[str], - clean_build: bool = True, - skip_if_already_installed: bool = True, -) -> None: - """Build the client""" - package_specifiers_to_install = list(packages) - del packages # delete since we just renamed it - - packages_to_install = _parse_package_specs(package_specifiers_to_install) - installed_packages = _private.build_dependencies() - - if clean_build: - all_packages = packages_to_install - else: - if skip_if_already_installed: - for pkg_name, pkg_ver in packages_to_install.items(): - if pkg_name not in installed_packages or ( - pkg_ver and installed_packages[pkg_name] != pkg_ver - ): - break - else: - logger.info(f"Already installed {package_specifiers_to_install}") - logger.info("Build skipped ✅") - return None - all_packages = {**installed_packages, **packages_to_install} - - all_package_specifiers = [f"{p}@{v}" if v else p for p, v in all_packages.items()] - - with TemporaryDirectory() as tempdir: - tempdir_path = Path(tempdir) - temp_app_dir = tempdir_path / "app" - temp_build_dir = temp_app_dir / "build" - package_json_path = temp_app_dir / "package.json" - - # copy over the whole APP_DIR directory into the temp one - shutil.copytree(_private.APP_DIR, temp_app_dir, symlinks=True) - - _write_user_packages_file(temp_app_dir, list(all_packages)) - - logger.info("Installing dependencies...") - _npm_install(all_package_specifiers, temp_app_dir) - logger.info("Installed successfully ✅") - - logger.debug(f"package.json: {package_json_path.read_text()}") - - logger.info("Building client ...") - _npm_run_build(temp_app_dir) - logger.info("Client built successfully ✅") - - _private.replace_build_dir(temp_build_dir) - - not_discovered = set(all_packages).difference(web_module_names()) - if not_discovered: - raise RuntimeError( # pragma: no cover - f"Successfuly installed {list(all_packages)} but " - f"failed to discover {list(not_discovered)} post-install." - ) - - -if sys.platform == "win32" and sys.version_info[:2] == (3, 7): # pragma: no cover - - def build( - packages: Sequence[str], - clean_build: bool = True, - skip_if_already_installed: bool = True, - ) -> None: - msg = ( - "This feature is not available due to a bug in Python<3.8 on Windows - for " - "more information see: https://bugs.python.org/issue31226" - ) - try: - import pytest - except ImportError: - raise NotImplementedError(msg) - else: - pytest.xfail(msg) - - -def _parse_package_specs(package_strings: Sequence[str]) -> Dict[str, str]: - return { - dep: ver - for dep, ver in map(_private.split_package_name_and_version, package_strings) - } - - -def _npm_install(packages: List[str], cwd: Path) -> None: - _run_subprocess(["npm", "install"] + packages, cwd) - - -def _npm_run_build(cwd: Path) -> None: - _run_subprocess(["npm", "run", "build"], cwd) - - -def _run_subprocess(args: List[str], cwd: Path) -> None: - cmd, *args = args - which_cmd = shutil.which(cmd) - if which_cmd is None: - raise RuntimeError( # pragma: no cover - f"Failed to run command - {cmd!r} is not installed." - ) - try: - subprocess.run( - [which_cmd] + args, - cwd=cwd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as error: # pragma: no cover - raise subprocess.SubprocessError(error.stderr.decode()) from error - return None - - -def _write_user_packages_file(app_dir: Path, packages: Iterable[str]) -> None: - _private.get_user_packages_file(app_dir).write_text( - _USER_PACKAGES_FILE_TEMPLATE.format( - imports=",".join(f'"{pkg}":import({pkg!r})' for pkg in packages) - ) - ) - - -_USER_PACKAGES_FILE_TEMPLATE = """// THIS FILE WAS GENERATED BY IDOM - DO NOT MODIFY -export default {{{imports}}}; -""" diff --git a/src/idom/client/module.py b/src/idom/client/module.py deleted file mode 100644 index 4a0e4acef..000000000 --- a/src/idom/client/module.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Client Modules -============== -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple, Union, overload -from urllib.parse import urlparse - -from idom.core.vdom import ImportSourceDict, VdomDict, make_vdom_constructor - -from . import _private, manage - - -@overload -def install( - packages: str, - ignore_installed: bool, - fallback: Optional[str], -) -> Module: - ... - - -@overload -def install( - packages: Union[List[str], Tuple[str]], - ignore_installed: bool, - fallback: Optional[str], -) -> List[Module]: - ... - - -def install( - packages: Union[str, List[str], Tuple[str]], - ignore_installed: bool = False, - fallback: Optional[str] = None, -) -> Union[Module, List[Module]]: - return_one = False - if isinstance(packages, str): - packages = [packages] - return_one = True - - pkg_names = [_private.get_package_name(pkg) for pkg in packages] - - if ignore_installed or set(pkg_names).difference(manage.web_module_names()): - manage.build(packages, clean_build=False) - - if return_one: - return Module(pkg_names[0], fallback=fallback) - else: - return [Module(pkg, fallback=fallback) for pkg in pkg_names] - - -NAME_SOURCE = "NAME" -"""A named souce - usually a Javascript package name""" - -URL_SOURCE = "URL" -"""A source loaded from a URL, usually from a CDN""" - -SOURCE_TYPES = {NAME_SOURCE, URL_SOURCE} -"""The possible source types for a :class:`Module`""" - - -class Module: - """A Javascript module - - Parameters: - source: - The URL to an ECMAScript module which exports React components - (*with* a ``.js`` file extension) or name of a module installed in the - built-in client application (*without* a ``.js`` file extension). - source_type: - The type of the given ``source``. See :const:`SOURCE_TYPES` for the set of - possible values. - file: - Only applicable if running on a client app which supports this feature. - Dynamically install the code in the give file as a single-file module. The - built-in client will inject this module adjacent to other installed modules - which means they can be imported via a relative path like - ``./some-other-installed-module.js``. - fallack: - What to display while the modules is being loaded. - - Attributes: - installed: - Whether or not this module has been installed into the built-in client app. - url: - The URL this module will be imported from. - """ - - __slots__ = "source", "source_type", "fallback", "exports" - - def __init__( - self, - source: str, - source_type: Optional[str] = None, - source_file: Optional[Union[str, Path]] = None, - fallback: Optional[str] = None, - check_exports: Optional[bool] = None, - ) -> None: - self.source = source - self.fallback = fallback - self.exports: Optional[Set[str]] = None - - if source_type is None: - self.source_type = URL_SOURCE if _is_url(source) else NAME_SOURCE - elif source_type in SOURCE_TYPES: - self.source_type = source_type - else: - raise ValueError(f"Invalid source type {source_type!r}") - - if self.source_type == URL_SOURCE: - if check_exports is True: - raise ValueError(f"Can't check exports for source type {source_type!r}") - elif source_file is not None: - raise ValueError(f"File given, but source type is {source_type!r}") - else: - return None - elif check_exports is None: - check_exports = True - - if source_file is not None: - manage.add_web_module(source, source_file) - elif not manage.web_module_exists(source): - raise ValueError(f"Module {source!r} does not exist") - - if check_exports: - self.exports = manage.web_module_exports(source) - - def declare( - self, - name: str, - has_children: bool = True, - fallback: Optional[str] = None, - ) -> Import: - """Return an :class:`Import` for the given :class:`Module` and ``name`` - - This roughly translates to the javascript statement - - .. code-block:: javascript - - import { name } from "module" - - Where ``name`` is the given name, and ``module`` is the :attr:`Module.url` of - this :class:`Module` instance. - """ - if self.exports is not None and name not in self.exports: - raise ValueError( - f"{self} does not export {name!r}, available options are {list(self.exports)}" - ) - - return Import( - name, - self.source, - self.source_type, - has_children, - fallback or self.fallback, - ) - - def __getattr__(self, name: str) -> Import: - if name[0].lower() == name[0]: - # component names should be capitalized - raise AttributeError(f"{self} has no attribute {name!r}") - return self.declare(name) - - def __eq__(self, other: Any) -> bool: - return ( - isinstance(other, Module) - and self.source == other.source - and self.source_type == other.source_type - ) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.source})" - - -class Import: - """Import a react module - - Once imported, you can instantiate the library's components by calling them - via attribute-access. - - Examples: - - .. code-block:: python - - victory = idom.Import("victory", "VictoryBar" install=True) - style = {"parent": {"width": "500px"}} - victory.VictoryBar({"style": style}, fallback="loading...") - """ - - __slots__ = "_constructor", "_import_source", "_name" - - def __init__( - self, - name: str, - source: str, - source_type: str, - has_children: bool = True, - fallback: Optional[str] = None, - ) -> None: - self._name = name - self._constructor = make_vdom_constructor(name, has_children) - self._import_source = ImportSourceDict( - source=source, - sourceType=source_type, - fallback=fallback, - ) - - def __call__( - self, - *args: Any, - **kwargs: Any, - ) -> VdomDict: - return self._constructor(import_source=self._import_source, *args, **kwargs) - - def __repr__(self) -> str: - info: Dict[str, Any] = {"name": self._name, **self._import_source} - strings = ", ".join(f"{k}={v!r}" for k, v in info.items()) - return f"{type(self).__name__}({strings})" - - -def _is_url(string: str) -> bool: - if string.startswith("/") or string.startswith("./") or string.startswith("../"): - return True - else: - parsed = urlparse(string) - return bool(parsed.scheme and parsed.netloc) diff --git a/src/idom/client/app/package-lock.json b/src/idom/client/package-lock.json similarity index 100% rename from src/idom/client/app/package-lock.json rename to src/idom/client/package-lock.json diff --git a/src/idom/client/app/package.json b/src/idom/client/package.json similarity index 100% rename from src/idom/client/app/package.json rename to src/idom/client/package.json diff --git a/src/idom/client/app/packages/idom-app-react/package.json b/src/idom/client/packages/idom-app-react/package.json similarity index 100% rename from src/idom/client/app/packages/idom-app-react/package.json rename to src/idom/client/packages/idom-app-react/package.json diff --git a/src/idom/client/app/packages/idom-app-react/src/index.js b/src/idom/client/packages/idom-app-react/src/index.js similarity index 78% rename from src/idom/client/app/packages/idom-app-react/src/index.js rename to src/idom/client/packages/idom-app-react/src/index.js index 50a639f9f..3f9f492fa 100644 --- a/src/idom/client/app/packages/idom-app-react/src/index.js +++ b/src/idom/client/packages/idom-app-react/src/index.js @@ -1,14 +1,5 @@ import { mountLayoutWithWebSocket } from "idom-client-react"; -// imported so static analysis knows to pick up files linked by user-packages.js -import("./user-packages.js").then((module) => { - for (const pkgName in module.default) { - module.default[pkgName].then((pkg) => { - console.log(`Loaded module '${pkgName}'`); - }); - } -}); - export function mount(mountPoint) { mountLayoutWithWebSocket( mountPoint, @@ -35,7 +26,7 @@ function getWebSocketEndpoint() { } function loadImportSource(source, sourceType) { - return import(sourceType == "NAME" ? `./${source}.js` : source); + return import(sourceType == "NAME" ? `/modules/${source}.js` : source); } function shouldReconnect() { diff --git a/src/idom/client/app/packages/idom-client-react/.gitignore b/src/idom/client/packages/idom-client-react/.gitignore similarity index 100% rename from src/idom/client/app/packages/idom-client-react/.gitignore rename to src/idom/client/packages/idom-client-react/.gitignore diff --git a/src/idom/client/app/packages/idom-client-react/README.md b/src/idom/client/packages/idom-client-react/README.md similarity index 100% rename from src/idom/client/app/packages/idom-client-react/README.md rename to src/idom/client/packages/idom-client-react/README.md diff --git a/src/idom/client/app/packages/idom-client-react/package-lock.json b/src/idom/client/packages/idom-client-react/package-lock.json similarity index 100% rename from src/idom/client/app/packages/idom-client-react/package-lock.json rename to src/idom/client/packages/idom-client-react/package-lock.json diff --git a/src/idom/client/app/packages/idom-client-react/package.json b/src/idom/client/packages/idom-client-react/package.json similarity index 100% rename from src/idom/client/app/packages/idom-client-react/package.json rename to src/idom/client/packages/idom-client-react/package.json diff --git a/src/idom/client/app/packages/idom-client-react/src/component.js b/src/idom/client/packages/idom-client-react/src/component.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/src/component.js rename to src/idom/client/packages/idom-client-react/src/component.js diff --git a/src/idom/client/app/packages/idom-client-react/src/event-to-object.js b/src/idom/client/packages/idom-client-react/src/event-to-object.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/src/event-to-object.js rename to src/idom/client/packages/idom-client-react/src/event-to-object.js diff --git a/src/idom/client/app/packages/idom-client-react/src/index.js b/src/idom/client/packages/idom-client-react/src/index.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/src/index.js rename to src/idom/client/packages/idom-client-react/src/index.js diff --git a/src/idom/client/app/packages/idom-client-react/src/mount.js b/src/idom/client/packages/idom-client-react/src/mount.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/src/mount.js rename to src/idom/client/packages/idom-client-react/src/mount.js diff --git a/src/idom/client/app/packages/idom-client-react/src/utils.js b/src/idom/client/packages/idom-client-react/src/utils.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/src/utils.js rename to src/idom/client/packages/idom-client-react/src/utils.js diff --git a/src/idom/client/app/packages/idom-client-react/tests/event-to-object.test.js b/src/idom/client/packages/idom-client-react/tests/event-to-object.test.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/tests/event-to-object.test.js rename to src/idom/client/packages/idom-client-react/tests/event-to-object.test.js diff --git a/src/idom/client/app/packages/idom-client-react/tests/tooling/dom.js b/src/idom/client/packages/idom-client-react/tests/tooling/dom.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/tests/tooling/dom.js rename to src/idom/client/packages/idom-client-react/tests/tooling/dom.js diff --git a/src/idom/client/app/packages/idom-client-react/tests/tooling/setup.js b/src/idom/client/packages/idom-client-react/tests/tooling/setup.js similarity index 100% rename from src/idom/client/app/packages/idom-client-react/tests/tooling/setup.js rename to src/idom/client/packages/idom-client-react/tests/tooling/setup.js diff --git a/src/idom/client/app/snowpack.config.js b/src/idom/client/snowpack.config.js similarity index 100% rename from src/idom/client/app/snowpack.config.js rename to src/idom/client/snowpack.config.js diff --git a/src/idom/config.py b/src/idom/config.py index fa3e3cd85..c3ae8292e 100644 --- a/src/idom/config.py +++ b/src/idom/config.py @@ -6,23 +6,12 @@ variables or, for those which allow it, a programatic interface. """ -import shutil from pathlib import Path -from typing import Any, List +from tempfile import TemporaryDirectory -from appdirs import user_data_dir - -import idom - -from ._option import ALL_OPTIONS as _ALL_OPTIONS from ._option import Option as _Option -def all_options() -> List[_Option[Any]]: - """Get a list of all options""" - return list(_ALL_OPTIONS) - - IDOM_DEBUG_MODE = _Option( "IDOM_DEBUG_MODE", default=False, @@ -38,9 +27,12 @@ def all_options() -> List[_Option[Any]]: is set to ``DEBUG``. """ -IDOM_CLIENT_BUILD_DIR = _Option( - "IDOM_CLIENT_BUILD_DIR", - default=Path(user_data_dir(idom.__name__, idom.__author__)) / "client", +# Because these web modules will be linked dynamically at runtime this can be temporary +_DEFAULT_WEB_MODULES_DIR = TemporaryDirectory() + +IDOM_WED_MODULES_DIR = _Option( + "IDOM_WED_MODULES_DIR", + default=Path(_DEFAULT_WEB_MODULES_DIR.name), validator=Path, ) """The location IDOM will use to store its client application @@ -50,11 +42,6 @@ def all_options() -> List[_Option[Any]]: a set of publically available APIs for working with the client. """ -# TODO: remove this in 0.30.0 -_DEPRECATED_BUILD_DIR = Path(__file__).parent / "client" / "build" -if _DEPRECATED_BUILD_DIR.exists(): # pragma: no cover - shutil.rmtree(_DEPRECATED_BUILD_DIR) - IDOM_FEATURE_INDEX_AS_DEFAULT_KEY = _Option( "IDOM_FEATURE_INDEX_AS_DEFAULT_KEY", default=False, diff --git a/src/idom/dialect.py b/src/idom/dialect.py deleted file mode 100644 index b2b74e5a1..000000000 --- a/src/idom/dialect.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -HTML Language Extension -======================= -""" - -import ast -from typing import Any, List, Optional, Tuple, Union - -import htm -from pyalect import Dialect, DialectError - - -class HtmlDialectTranspiler(Dialect, name="html"): - """An HTML dialect transpiler for Python.""" - - def __init__(self, filename: Optional[str] = None): - self.filename: str = filename or "" - - def transform_src(self, source: str) -> str: - return source - - def transform_ast(self, node: ast.AST) -> ast.AST: - new_node: ast.AST = HtmlDialectNodeTransformer(self.filename).visit(node) - return new_node - - -class HtmlDialectNodeTransformer(ast.NodeTransformer): - def __init__(self, filename: str): - super().__init__() - self.filename = filename - - def visit_Call(self, node: ast.Call) -> Optional[ast.AST]: - if isinstance(node.func, ast.Name): - if node.func.id == "html": - if ( - not node.keywords - and len(node.args) == 1 - and isinstance(node.args[0], ast.JoinedStr) - ): - try: - new_node = self._transform_string(node.args[0]) - except htm.ParseError as error: - raise DialectError(str(error), self.filename, node.lineno) - return self.generic_visit( - ast.fix_missing_locations(ast.copy_location(new_node, node)) - ) - return node - - def _transform_string(self, node: ast.JoinedStr) -> ast.Call: - htm_strings: List[str] = [] - exp_nodes: List[ast.AST] = [] - for inner_node in node.values: - if isinstance(inner_node, ast.Str): - htm_strings.append(inner_node.s) - elif isinstance(inner_node, ast.FormattedValue): - if len(htm_strings) == len(exp_nodes): - htm_strings.append("") - if inner_node.conversion != -1 or inner_node.format_spec: - exp_nodes.append(ast.JoinedStr([inner_node])) - else: - exp_nodes.append(inner_node.value) - - call_stack = _HtmlCallStack() - for op_type, *data in htm.htm_parse(htm_strings): - getattr(self, f"_transform_htm_{op_type.lower()}")( - exp_nodes, call_stack, *data - ) - return call_stack.finish() - - def _transform_htm_open( - self, - exp_nodes: List[ast.AST], - call_stack: "_HtmlCallStack", - is_index: bool, - tag_or_index: Union[str, int], - ) -> None: - if isinstance(tag_or_index, int): - call_stack.begin_child(exp_nodes[tag_or_index]) - else: - call_stack.begin_child(ast.Str(tag_or_index)) - - def _transform_htm_close( - self, exp_nodes: List[ast.AST], call_stack: "_HtmlCallStack" - ) -> None: - call_stack.end_child() - - def _transform_htm_spread( - self, exp_nodes: List[ast.AST], call_stack: "_HtmlCallStack", _: Any, index: int - ) -> None: - call_stack.add_attributes(None, exp_nodes[index]) - - def _transform_htm_prop_single( - self, - exp_nodes: List[ast.AST], - call_stack: "_HtmlCallStack", - attr: str, - is_index: bool, - value_or_index: Union[str, int], - ) -> None: - if isinstance(value_or_index, bool): - const = ast.NameConstant(value_or_index) - call_stack.add_attributes(ast.Str(attr), const) - elif isinstance(value_or_index, int): - call_stack.add_attributes(ast.Str(attr), exp_nodes[value_or_index]) - else: - call_stack.add_attributes(ast.Str(attr), ast.Str(value_or_index)) - - def _transform_htm_prop_multi( - self, - exp_nodes: List[ast.AST], - call_stack: "_HtmlCallStack", - attr: str, - items: Tuple[Tuple[bool, Union[str, int]]], - ) -> None: - op_root = current_op = ast.BinOp(None, None, None) - for _, value_or_index in items: - if isinstance(value_or_index, str): - current_op.right = ast.BinOp(ast.Str(value_or_index), ast.Add(), None) - else: - current_op.right = ast.BinOp(exp_nodes[value_or_index], ast.Add(), None) - last_op = current_op - current_op = current_op.right - last_op.right = current_op.left - call_stack.add_attributes(ast.Str(attr), op_root.right) - - def _transform_htm_child( - self, - exp_nodes: List[ast.AST], - call_stack: "_HtmlCallStack", - is_index: bool, - child_or_index: Union[str, int], - ) -> None: - if isinstance(child_or_index, int): - call_stack.add_child(exp_nodes[child_or_index]) - else: - call_stack.add_child(ast.Str(child_or_index)) - - -class _HtmlCallStack: - def __init__(self) -> None: - self._root = self._new(ast.Str()) - self._stack: List[ast.Call] = [self._root] - - def begin_child(self, tag: ast.AST) -> None: - new = self._new(tag) - last = self._stack[-1] - children = last.args[2].elts # type: ignore - children.append(new) - self._stack.append(new) - - def add_child(self, child: ast.AST) -> None: - current = self._stack[-1] - children = current.args[2].elts # type: ignore - children.append(child) - - def add_attributes(self, key: Optional[ast.Str], value: ast.AST) -> None: - current = self._stack[-1] - attributes: ast.Dict = current.args[1] # type: ignore - attributes.keys.append(key) - attributes.values.append(value) # type: ignore - - def end_child(self) -> None: - self._stack.pop(-1) - - def finish(self) -> ast.Call: - root = self._root - self._root = self._new(ast.Str()) - self._stack.clear() - return root.args[2].elts[0] # type: ignore - - @staticmethod - def _new(tag: ast.AST) -> ast.Call: - args = [tag, ast.Dict([], []), ast.List([], ast.Load())] - return ast.Call(ast.Name("html", ast.Load()), args, []) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 844567a91..fcd27172f 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -23,7 +23,7 @@ from uvicorn.supervisors.multiprocess import Multiprocess from uvicorn.supervisors.statreload import StatReload as ChangeReload -from idom.config import IDOM_CLIENT_BUILD_DIR +from idom.config import IDOM_WED_MODULES_DIR from idom.core.component import ComponentConstructor from idom.core.dispatcher import ( RecvCoroutine, @@ -34,7 +34,7 @@ ) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from .utils import poll, threaded +from .utils import CLIENT_BUILD_DIR, poll, threaded logger = logging.getLogger(__name__) @@ -195,7 +195,16 @@ def _setup_common_routes(app: FastAPI, router: APIRouter, config: Config) -> Non app.mount( f"{url_prefix}/client", StaticFiles( - directory=str(IDOM_CLIENT_BUILD_DIR.current), + directory=str(CLIENT_BUILD_DIR), + html=True, + check_dir=True, + ), + name="idom_static_files", + ) + app.mount( + f"{url_prefix}/modules", + StaticFiles( + directory=str(IDOM_WED_MODULES_DIR.current), html=True, check_dir=True, ), diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index b5e1696c6..3f58a0d20 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -23,12 +23,12 @@ from typing_extensions import TypedDict import idom -from idom.config import IDOM_CLIENT_BUILD_DIR, IDOM_DEBUG_MODE +from idom.config import IDOM_DEBUG_MODE, IDOM_WED_MODULES_DIR from idom.core.component import AbstractComponent, ComponentConstructor from idom.core.dispatcher import dispatch_single_view from idom.core.layout import LayoutEvent, LayoutUpdate -from .utils import threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event logger = logging.getLogger(__name__) @@ -136,8 +136,12 @@ def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: if config["serve_static_files"]: @blueprint.route("/client/") - def send_build_dir(path: str) -> Any: - return send_from_directory(str(IDOM_CLIENT_BUILD_DIR.current), path) + def send_client_dir(path: str) -> Any: + return send_from_directory(str(CLIENT_BUILD_DIR), path) + + @blueprint.route("/modules/") + def send_modules_dir(path: str) -> Any: + return send_from_directory(str(IDOM_WED_MODULES_DIR.current), path) if config["redirect_root_to_index"]: @@ -145,7 +149,7 @@ def send_build_dir(path: str) -> Any: def redirect_to_index() -> Any: return redirect( url_for( - "idom.send_build_dir", + "idom.send_client_dir", path="index.html", **request.args, ) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 71d5d931e..924fa1c52 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -17,7 +17,7 @@ from sanic_cors import CORS from websockets import WebSocketCommonProtocol -from idom.config import IDOM_CLIENT_BUILD_DIR +from idom.config import IDOM_WED_MODULES_DIR from idom.core.component import ComponentConstructor from idom.core.dispatcher import ( RecvCoroutine, @@ -28,7 +28,7 @@ ) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from .utils import threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event logger = logging.getLogger(__name__) @@ -172,7 +172,8 @@ def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: CORS(blueprint, **cors_params) if config["serve_static_files"]: - blueprint.static("/client", str(IDOM_CLIENT_BUILD_DIR.current)) + blueprint.static("/client", str(CLIENT_BUILD_DIR)) + blueprint.static("/modules", str(IDOM_WED_MODULES_DIR.current)) if config["redirect_root_to_index"]: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 46d29dd78..6eb419ad1 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -18,12 +18,12 @@ from tornado.websocket import WebSocketHandler from typing_extensions import TypedDict -from idom.config import IDOM_CLIENT_BUILD_DIR +from idom.config import IDOM_WED_MODULES_DIR from idom.core.component import ComponentConstructor from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from .utils import threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event _RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] @@ -126,7 +126,14 @@ def _setup_common_routes(config: Config) -> _RouteHandlerSpecs: ( r"/client/(.*)", StaticFileHandler, - {"path": str(IDOM_CLIENT_BUILD_DIR.current)}, + {"path": str(CLIENT_BUILD_DIR)}, + ) + ) + handlers.append( + ( + r"/modules/(.*)", + StaticFileHandler, + {"path": str(IDOM_WED_MODULES_DIR.current)}, ) ) if config["redirect_root_to_index"]: diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index 781e523f9..410bd7cc4 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -3,13 +3,18 @@ from contextlib import closing from functools import wraps from importlib import import_module +from pathlib import Path from socket import socket from threading import Event, Thread from typing import Any, Callable, List, Optional, TypeVar, cast +import idom + from .proto import ServerFactory +CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" / "build" + _SUPPORTED_PACKAGES = [ "sanic", "fastapi", @@ -17,7 +22,6 @@ "tornado", ] - _Func = TypeVar("_Func", bound=Callable[..., None]) diff --git a/src/idom/testing.py b/src/idom/testing.py index 02a8b6edc..f35117455 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -43,8 +43,8 @@ def create_simple_selenium_web_driver( driver_type: Type[WebDriver] = Chrome, driver_options: Optional[Any] = None, - implicit_wait_timeout: float = 3.0, - page_load_timeout: float = 3.0, + implicit_wait_timeout: float = 5.0, + page_load_timeout: float = 5.0, window_size: Tuple[int, int] = (1080, 800), ) -> WebDriver: driver = driver_type(options=driver_options) diff --git a/src/idom/web/__init__.py b/src/idom/web/__init__.py new file mode 100644 index 000000000..d3187366c --- /dev/null +++ b/src/idom/web/__init__.py @@ -0,0 +1,9 @@ +from .module import export, module_from_file, module_from_template, module_from_url + + +__all__ = [ + "module_from_file", + "module_from_template", + "module_from_url", + "export", +] diff --git a/src/idom/web/module.py b/src/idom/web/module.py new file mode 100644 index 000000000..9e1a9311d --- /dev/null +++ b/src/idom/web/module.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import Any, List, NewType, Optional, Set, Tuple, Union, overload + +from idom.config import IDOM_DEBUG_MODE +from idom.core.vdom import ImportSourceDict, VdomDictConstructor, make_vdom_constructor + +from .utils import ( + resolve_module_exports_from_file, + resolve_module_exports_from_url, + web_module_path, +) + + +SourceType = NewType("SourceType", str) + +NAME_SOURCE = SourceType("NAME") +"""A named souce - usually a Javascript package name""" + +URL_SOURCE = SourceType("URL") +"""A source loaded from a URL, usually from a CDN""" + + +def module_from_url( + url: str, + fallback: Optional[Any] = None, + resolve_exports: bool = IDOM_DEBUG_MODE.current, + resolve_exports_depth: int = 5, +) -> WebModule: + return WebModule( + source=url, + source_type=URL_SOURCE, + default_fallback=fallback, + file=None, + export_names=( + resolve_module_exports_from_url(url, resolve_exports_depth) + if resolve_exports + else None + ), + ) + + +def module_from_template( + template: str, + name: str, + cdn: str = "https://esm.sh", + fallback: Optional[Any] = None, + resolve_exports: bool = IDOM_DEBUG_MODE.current, + resolve_exports_depth: int = 5, +) -> WebModule: + cdn = cdn.rstrip("/") + + template_file = Path(__file__).parent / "templates" / f"{template}.js" + if not template_file.exists(): + raise ValueError(f"No template for {template!r} exists") + + target_file = web_module_path(name) + if not target_file.exists(): + target_file.parent.mkdir(parents=True, exist_ok=True) + target_file.write_text( + template_file.read_text().replace("$PACKAGE", name).replace("$CDN", cdn) + ) + + return WebModule( + source=name, + source_type=NAME_SOURCE, + default_fallback=fallback, + file=target_file, + export_names=( + resolve_module_exports_from_url(f"{cdn}/{name}", resolve_exports_depth) + if resolve_exports + else None + ), + ) + + +def module_from_file( + name: str, + file: Union[str, Path], + fallback: Optional[Any] = None, + resolve_exports: bool = IDOM_DEBUG_MODE.current, + resolve_exports_depth: int = 5, +) -> WebModule: + source_file = Path(file) + target_file = web_module_path(name) + if target_file.exists(): + if target_file.resolve() != source_file.resolve(): + raise ValueError(f"{name!r} already exists as {target_file.resolve()}") + else: + target_file.parent.mkdir(parents=True, exist_ok=True) + target_file.symlink_to(source_file) + return WebModule( + source=name, + source_type=NAME_SOURCE, + default_fallback=fallback, + file=target_file, + export_names=( + resolve_module_exports_from_file(source_file, resolve_exports_depth) + if resolve_exports + else None + ), + ) + + +@dataclass(frozen=True) +class WebModule: + source: str + source_type: SourceType + default_fallback: Optional[Any] + export_names: Optional[Set[str]] + file: Optional[Path] + + +@overload +def export( + web_module: WebModule, + export_names: str, + fallback: Optional[Any], + allow_children: bool, +) -> VdomDictConstructor: + ... + + +@overload +def export( + web_module: WebModule, + export_names: Union[List[str], Tuple[str]], + fallback: Optional[Any], + allow_children: bool, +) -> List[VdomDictConstructor]: + ... + + +def export( + web_module: WebModule, + export_names: Union[str, List[str], Tuple[str]], + fallback: Optional[Any] = None, + allow_children: bool = True, +) -> Union[VdomDictConstructor, List[VdomDictConstructor]]: + if isinstance(export_names, str): + if ( + web_module.export_names is not None + and export_names not in web_module.export_names + ): + raise ValueError(f"{web_module.source!r} does not export {export_names!r}") + return _make_export(web_module, export_names, fallback, allow_children) + else: + if web_module.export_names is not None: + missing = list(set(export_names).difference(web_module.export_names)) + if missing: + raise ValueError(f"{web_module.source!r} does not export {missing!r}") + return [ + _make_export(web_module, name, fallback, allow_children) + for name in export_names + ] + + +def _make_export( + web_module: WebModule, name: str, fallback: Optional[Any], allow_children: bool +) -> VdomDictConstructor: + return partial( + make_vdom_constructor( + name, + allow_children=allow_children, + ), + import_source=ImportSourceDict( + source=web_module.source, + sourceType=web_module.source_type, + fallback=(fallback or web_module.default_fallback), + ), + ) diff --git a/src/idom/web/templates/preact.js b/src/idom/web/templates/preact.js new file mode 100644 index 000000000..bce648206 --- /dev/null +++ b/src/idom/web/templates/preact.js @@ -0,0 +1,12 @@ +export * from "$CDN/$PACKAGE"; + +import { h, Component, render } from "$CDN/preact"; +import htm from "$CDN/htm"; + +const html = htm.bind(h); + +export { h as createElement, render as renderElement }; + +export function unmountElement(container) { + preactRender(null, container); +} diff --git a/src/idom/web/templates/react.js b/src/idom/web/templates/react.js new file mode 100644 index 000000000..1b27f42ff --- /dev/null +++ b/src/idom/web/templates/react.js @@ -0,0 +1,8 @@ +export * from "$CDN/$PACKAGE"; + +import * as react from "$CDN/react"; +import * as reactDOM from "$CDN/react-dom"; + +export const createElement = (component, props) => react.createElement(component, props); +export const renderElement = reactDOM.render; +export const unmountElement = reactDOM.unmountComponentAtNode; diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py new file mode 100644 index 000000000..03f9354bd --- /dev/null +++ b/src/idom/web/utils.py @@ -0,0 +1,96 @@ +import logging +import re +from pathlib import Path +from typing import Set, Tuple +from urllib.parse import urlparse + +import requests + +from idom.config import IDOM_WED_MODULES_DIR + + +logger = logging.getLogger(__name__) + + +def web_module_path(name: str) -> Path: + path = IDOM_WED_MODULES_DIR.current.joinpath(*name.split("/")) + return path.with_suffix(path.suffix + ".js") + + +def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: + export_names, references = resolve_module_exports_from_source(file.read_text()) + if max_depth == 0: + logger.warning(f"Unable to resolve all exports for {file}") + else: + for ref in references: + if urlparse(ref).scheme: # is an absolute URL + export_names.update(resolve_module_exports_from_url(ref, max_depth - 1)) + elif ref.startswith("."): + path = _resolve_relative_file_path(file, ref) + export_names.update( + resolve_module_exports_from_file(path, max_depth - 1) + ) + else: + logger.warning(f"Did not resolve exports for unknown location {ref}") + return export_names + + +def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: + export_names, references = resolve_module_exports_from_source( + requests.get(url).text + ) + if max_depth == 0: + logger.warning(f"Unable to fully resolve all exports for {url}") + else: + for ref in references: + url = _resolve_relative_url(url, ref) + export_names.update(resolve_module_exports_from_url(url, max_depth - 1)) + return export_names + + +def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str]]: + names: Set[str] = set() + for match in _JS_MODULE_EXPORT_PATTERN.findall(content): + for export in match.split(","): + export_parts = export.split(" as ", 1) + names.add(export_parts[-1].strip()) + names.update(_JS_MODULE_EXPORT_FUNC_PATTERN.findall(content)) + names.update(_JS_MODULE_EXPORT_NAME_PATTERN.findall(content)) + + references: Set[str] = set(_JS_MODULE_EXPORT_FROM_REF_PATTERN.findall(content)) + return names, references + + +def _resolve_relative_file_path(base_path: Path, rel_url: str) -> Path: + if rel_url.startswith("./"): + return base_path.parent / rel_url[2:] + while rel_url.startswith("../"): + base_path = base_path.parent + rel_url = rel_url[3:] + return base_path / rel_url + + +def _resolve_relative_url(base_url: str, rel_url: str) -> str: + if not rel_url.startswith("."): + return rel_url + elif rel_url.startswith("./"): + return base_url.rsplit("/")[0] + rel_url[1:] + while rel_url.startswith("../"): + base_url = base_url.rsplit("/")[0] + rel_url = rel_url[3:] + return f"{base_url}/{rel_url}" + + +_JS_MODULE_EXPORT_PATTERN = re.compile( + r";?\s*export\s*{([0-9a-zA-Z_$\s,]*)}\s*;", re.MULTILINE +) +_JS_VAR = r"[a-zA-Z_$][0-9a-zA-Z_$]*" +_JS_MODULE_EXPORT_NAME_PATTERN = re.compile( + fr";?\s*export\s+({_JS_VAR})\s+{_JS_VAR}\s*;", re.MULTILINE +) +_JS_MODULE_EXPORT_FUNC_PATTERN = re.compile( + fr";?\s*export\s+function\s+({_JS_VAR})\s*\(.*?", re.MULTILINE +) +_JS_MODULE_EXPORT_FROM_REF_PATTERN = re.compile( + r""";?\s*export\s+\*\s+from\s+['"](.*?)['"];""" +) diff --git a/tests/conftest.py b/tests/conftest.py index 9bf8a6e42..7569a84fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,8 @@ import inspect import os -from typing import Any, Iterator, List +from typing import Any, List -import pyalect.builtins.pytest # noqa import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -12,7 +11,6 @@ from selenium.webdriver.support.ui import WebDriverWait import idom -from idom.client import manage as manage_client from idom.testing import ServerMountPoint, create_simple_selenium_web_driver @@ -104,22 +102,6 @@ def driver_is_headless(pytestconfig: Config): return bool(pytestconfig.option.headless) -@pytest.fixture(scope="session", autouse=True) -def _restore_client(pytestconfig: Config) -> Iterator[None]: - """Restore the client's state before and after testing - - For faster test runs set ``--no-restore-client`` at the CLI. Test coverage and - breakages may occur if this is set. Further the client is not cleaned up - after testing and may effect usage of IDOM beyond the scope of the tests. - """ - if pytestconfig.option.restore_client: - manage_client.restore() - yield - manage_client.restore() - else: - yield - - def _mark_coros_as_async_tests(items: List[pytest.Item]) -> None: for item in items: if isinstance(item, pytest.Function): diff --git a/tests/test_client/test_app.py b/tests/test_app.py similarity index 59% rename from tests/test_client/test_app.py rename to tests/test_app.py index 793c03c20..9fbf50b74 100644 --- a/tests/test_client/test_app.py +++ b/tests/test_app.py @@ -43,35 +43,3 @@ def NewComponent(): # check that we can resume normal operation set_state.current(1) driver.find_element_by_id("new-component-1") - - -def test_that_js_module_unmount_is_called(driver, driver_wait, display): - module = idom.Module( - "set-flag-when-unmount-is-called", - source_file=JS_DIR / "set-flag-when-unmount-is-called.js", - ) - - set_current_component = idom.Ref(None) - - @idom.component - def ShowCurrentComponent(): - current_component, set_current_component.current = idom.hooks.use_state( - lambda: module.SomeComponent( - {"id": "some-component", "text": "initial component"} - ) - ) - return current_component - - display(ShowCurrentComponent) - - driver.find_element_by_id("some-component") - - set_current_component.current( - idom.html.h1({"id": "some-other-component"}, "some other component") - ) - - # the new component has been displayed - driver.find_element_by_id("some-other-component") - - # the unmount callback for the old component was called - driver.find_element_by_id("unmount-flag") diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index aa59fd922..000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,37 +0,0 @@ -from typer.testing import CliRunner - -import idom -from idom.cli import main -from idom.client.manage import web_module_exists -from idom.config import all_options - - -cli_runner = CliRunner() - - -def test_root(): - assert idom.__version__ in cli_runner.invoke(main, ["--version"]).stdout - - -def test_install(): - cli_runner.invoke(main, ["restore"]) - assert cli_runner.invoke(main, ["install", "jquery"]).exit_code == 0 - assert web_module_exists("jquery") - - result = cli_runner.invoke(main, ["install", "jquery"]) - print(result.stdout) - assert result.exit_code == 0 - assert "Already installed ['jquery']" in result.stdout - assert "Build skipped" in result.stdout - assert web_module_exists("jquery") - - -def test_restore(): - assert cli_runner.invoke(main, ["restore"]).exit_code == 0 - - -def test_options(): - assert cli_runner.invoke(main, ["options"]).stdout.strip().split("\n") == [ - f"{opt.name} = {opt.current}" - for opt in sorted(all_options(), key=lambda o: o.name) - ] diff --git a/tests/test_client/__init__.py b/tests/test_client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_client/test__private.py b/tests/test_client/test__private.py deleted file mode 100644 index 9044d802e..000000000 --- a/tests/test_client/test__private.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from idom.client._private import ( - find_js_module_exports_in_source, - split_package_name_and_version, -) -from tests.general_utils import assert_same_items - - -@pytest.mark.parametrize( - "module_source, expected_names", - [ - ("asdfasdfasdf;export{one as One, two as Two};asdfasdf;", ["One", "Two"]), - ("asd;export{one as One};asdfasdf;export{two as Two};", ["One", "Two"]), - ("asdasd;export default something;", ["default"]), - ("asdasd;export One something;asdfa;export Two somethingElse;", ["One", "Two"]), - ( - "asdasd;export One something;asdfa;export{two as Two};asdfasdf;", - ["One", "Two"], - ), - ], -) -def test_find_js_module_exports_in_source(module_source, expected_names): - assert_same_items(find_js_module_exports_in_source(module_source), expected_names) - - -@pytest.mark.parametrize( - "package_specifier,expected_name_and_version", - [ - ("package", ("package", "")), - ("package@1.2.3", ("package", "1.2.3")), - ("@scope/pkg", ("@scope/pkg", "")), - ("@scope/pkg@1.2.3", ("@scope/pkg", "1.2.3")), - ("alias@npm:package", ("alias", "npm:package")), - ("alias@npm:package@1.2.3", ("alias", "npm:package@1.2.3")), - ("alias@npm:@scope/pkg@1.2.3", ("alias", "npm:@scope/pkg@1.2.3")), - ("@alias/pkg@npm:@scope/pkg@1.2.3", ("@alias/pkg", "npm:@scope/pkg@1.2.3")), - ], -) -def test_split_package_name_and_version(package_specifier, expected_name_and_version): - assert ( - split_package_name_and_version(package_specifier) == expected_name_and_version - ) diff --git a/tests/test_client/test_manage.py b/tests/test_client/test_manage.py deleted file mode 100644 index a494d7b5c..000000000 --- a/tests/test_client/test_manage.py +++ /dev/null @@ -1,151 +0,0 @@ -import pytest - -import idom -from idom.client.manage import ( - add_web_module, - build, - remove_web_module, - restore, - web_module_exists, - web_module_exports, - web_module_path, -) -from tests.general_utils import assert_same_items - - -@pytest.fixture(scope="module") -def victory(): - return idom.install("victory@35.4.0") - - -def test_clean_build(): - restore() - build(["jquery"]) - assert web_module_exists("jquery") - build([], clean_build=True) - assert not web_module_exists("jquery") - - -def test_add_web_module_source_must_exist(tmp_path): - with pytest.raises(FileNotFoundError, match="Package source file does not exist"): - add_web_module("test", tmp_path / "file-does-not-exist.js") - - -def test_cannot_add_web_module_if_already_exists(tmp_path): - first_temp_file = tmp_path / "temp-1.js" - second_temp_file = tmp_path / "temp-2.js" - - first_temp_file.write_text("console.log('hello!')") # this won't get run - second_temp_file.write_text("console.log('hello!')") # this won't get run - - add_web_module("test", first_temp_file) - with pytest.raises(FileExistsError): - add_web_module("test", second_temp_file) - - remove_web_module("test") - - -def test_can_add_web_module_if_already_exists_and_source_is_same(tmp_path): - temp_file = tmp_path / "temp.js" - temp_file.write_text("console.log('hello!')") - add_web_module("test", temp_file) - add_web_module("test", temp_file) - remove_web_module("test") - - -def test_web_module_path_must_exist(): - with pytest.raises(ValueError, match="does not exist at path"): - web_module_path("this-does-not-exist", must_exist=True) - assert not web_module_path("this-does-not-exist", must_exist=False).exists() - - -def test_web_module_exports(victory): - assert_same_items( - web_module_exports("victory"), - [ - "Area", - "Axis", - "Background", - "Bar", - "Border", - "Box", - "BrushHelpers", - "Candle", - "Circle", - "ClipPath", - "Collection", - "CursorHelpers", - "Curve", - "Data", - "DefaultTransitions", - "Domain", - "ErrorBar", - "Events", - "Flyout", - "Helpers", - "LabelHelpers", - "Line", - "LineSegment", - "Log", - "Path", - "Point", - "Portal", - "PropTypes", - "RawZoomHelpers", - "Rect", - "Scale", - "Selection", - "SelectionHelpers", - "Slice", - "Style", - "TSpan", - "Text", - "TextSize", - "Transitions", - "VictoryAnimation", - "VictoryArea", - "VictoryAxis", - "VictoryBar", - "VictoryBoxPlot", - "VictoryBrushContainer", - "VictoryBrushLine", - "VictoryCandlestick", - "VictoryChart", - "VictoryClipContainer", - "VictoryContainer", - "VictoryCursorContainer", - "VictoryErrorBar", - "VictoryGroup", - "VictoryHistogram", - "VictoryLabel", - "VictoryLegend", - "VictoryLine", - "VictoryPie", - "VictoryPolarAxis", - "VictoryPortal", - "VictoryScatter", - "VictorySelectionContainer", - "VictorySharedEvents", - "VictoryStack", - "VictoryTheme", - "VictoryTooltip", - "VictoryTransition", - "VictoryVoronoi", - "VictoryVoronoiContainer", - "VictoryZoomContainer", - "Voronoi", - "VoronoiHelpers", - "Whisker", - "Wrapper", - "ZoomHelpers", - "addEvents", - "brushContainerMixin", - "combineContainerMixins", - "createContainer", - "cursorContainerMixin", - "makeCreateContainerFunction", - "selectionContainerMixin", - "voronoiContainerMixin", - "zoomContainerMixin", - ], - ) diff --git a/tests/test_client/test_module.py b/tests/test_client/test_module.py deleted file mode 100644 index 1b26cde4d..000000000 --- a/tests/test_client/test_module.py +++ /dev/null @@ -1,116 +0,0 @@ -from pathlib import Path - -import pytest - -import idom -from idom import Module -from idom.client.module import URL_SOURCE - - -HERE = Path(__file__).parent -JS_FIXTURES = HERE / "js" - - -@pytest.fixture -def victory(): - return idom.install("victory@35.4.0") - - -@pytest.fixture(scope="module") -def simple_button(): - return Module("simple-button", source_file=JS_FIXTURES / "simple-button.js") - - -def test_any_relative_or_abolute_url_allowed(): - Module("/absolute/url/module") - Module("./relative/url/module") - Module("../relative/url/module") - Module("http://someurl.com/module") - - -def test_module_import_repr(): - assert ( - repr(Module("/absolute/url/module").declare("SomeComponent")) - == "Import(name='SomeComponent', source='/absolute/url/module', sourceType='URL', fallback=None)" - ) - - -def test_install_multiple(): - # install several random JS packages - pad_left, decamelize, is_sorted = idom.install( - ["pad-left", "decamelize", "is-sorted"] - ) - # ensure the output order is the same as the input order - assert pad_left.source.endswith("pad-left") - assert decamelize.source.endswith("decamelize") - assert is_sorted.source.endswith("is-sorted") - - -def test_module_does_not_exist(): - with pytest.raises(ValueError, match="does not exist"): - Module("this-module-does-not-exist") - - -def test_installed_module(driver, display, victory): - display(victory.VictoryBar) - driver.find_element_by_class_name("VictoryContainer") - - -def test_reference_pre_installed_module(victory): - assert victory == idom.Module("victory") - - -def test_module_from_url(): - url = "https://code.jquery.com/jquery-3.5.0.js" - jquery = idom.Module(url) - assert jquery.source == url - assert jquery.source_type == URL_SOURCE - assert jquery.exports is None - - -def test_module_from_source(driver, driver_wait, display, simple_button): - response_data = idom.Ref(None) - - @idom.component - def ShowButton(): - return simple_button.SimpleButton( - { - "id": "test-button", - "onClick": lambda event: response_data.set_current(event["data"]), - "eventResponseData": 10, - } - ) - - display(ShowButton) - - client_button = driver.find_element_by_id("test-button") - client_button.click() - driver_wait.until(lambda dvr: response_data.current == 10) - - -def test_module_checks_export_names(simple_button): - with pytest.raises(ValueError, match="does not export 'ComponentDoesNotExist'"): - simple_button.declare("ComponentDoesNotExist") - - -def test_cannot_have_source_file_for_url_source_type(): - with pytest.raises(ValueError, match="File given, but source type is 'URL'"): - idom.Module("test", source_file="something.js", source_type=URL_SOURCE) - - -def test_cannot_check_exports_for_url_source_type(): - with pytest.raises(ValueError, match="Can't check exports for source type 'URL'"): - idom.Module("test", check_exports=True, source_type=URL_SOURCE) - - -def test_invalid_source_type(): - with pytest.raises(ValueError, match="Invalid source type"): - idom.Module("test", source_type="TYPE_DOES_NOT_EXIST") - - -def test_attribute_error_if_lowercase_name_doesn_not_exist(): - mod = idom.Module("test", source_type=URL_SOURCE) - with pytest.raises(AttributeError, match="this_attribute_does_not_exist"): - # This attribute would otherwise be considered to - # be the name of a component the module exports. - mod.this_attribute_does_not_exist diff --git a/tests/test_client/utils.py b/tests/test_client/utils.py deleted file mode 100644 index 404998db7..000000000 --- a/tests/test_client/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -from contextlib import contextmanager -from pathlib import Path - - -@contextmanager -def assert_file_is_touched(path): - path = Path(path) - last_modified = path.stat().st_mtime - yield - assert last_modified != path.stat().st_mtime diff --git a/tests/test_dialect.py b/tests/test_dialect.py deleted file mode 100644 index 7cb806a38..000000000 --- a/tests/test_dialect.py +++ /dev/null @@ -1,269 +0,0 @@ -import ast -from typing import Any, Dict, Tuple - -import pytest -from pyalect import DialectError, apply_dialects - -from idom import html - - -def eval_html(src, variables=None): - tree = apply_dialects(src, "html") - if len(tree.body) > 1 or not isinstance(tree.body[0], ast.Expr): - raise ValueError(f"Expected a single expression, not {src!r}") - code = compile(ast.Expression(tree.body[0].value), "", "eval") - return eval(code, {"html": html}, variables) - - -def make_html_dialect_test(*expectations: Tuple[str, Dict[str, Any], Any]): - def make_ids(exp): - source, variables = exp[:2] - source_repr = repr(source) - if len(source_repr) > 30: - source_repr = source_repr[:30] + "'..." - variables_repr = repr(variables) - if len(variables_repr) > 30: - variables_repr = variables_repr[:30] + "...}" - return source_repr + "-" + variables_repr - - @pytest.mark.parametrize("expect", expectations, ids=make_ids) - def test_html_dialect(expect): - source, variables, result = expect - assert eval_html(source, variables) == result - - return test_html_dialect - - -test_simple_htm_template = make_html_dialect_test( - ('html(f"
")', {}, {"tagName": "div"}) -) - -test_value_children = make_html_dialect_test( - ('html(f"
foo
")', {}, {"tagName": "div", "children": ["foo"]}), - ( - 'html(f"
")', - {}, - {"tagName": "div", "children": [{"tagName": "span"}]}, - ), -) - -test_expression_children = make_html_dialect_test( - ( - 'html(f"
{value}
")', - {"value": "foo"}, - {"tagName": "div", "children": ["foo"]}, - ), - ( - """html(f"
{html(f'')}
")""", - {}, - {"tagName": "div", "children": [{"tagName": "span"}]}, - ), -) - -test_preserve_whitespace_between_text_values = make_html_dialect_test( - ( - """html(f"
a {'b'} c
")""", - {}, - {"tagName": "div", "children": [" a ", "b", " c "]}, - ) -) - -test_collapse_whitespace_lines_in_text = make_html_dialect_test( - ( - r'html(f"
\n a b c \n
")', - {}, - {"tagName": "div", "children": ["a b c"]}, - ), - ( - r"""html(f"
a \n {'b'} \n c \n
")""", - {}, - {"tagName": "div", "children": ["a", "b", "c"]}, - ), -) - -test_value_tag = make_html_dialect_test( - ('html(f"
")', {}, {"tagName": "div"}), - ('html(f"
")', {}, {"tagName": "div"}), - ("""html(f"<'div' />")""", {}, {"tagName": "div"}), - ("""html(f'<"div" />')""", {}, {"tagName": "div"}), -) - -test_expression_tag = make_html_dialect_test( - ('html(f"<{tag} />")', {"tag": "div"}, {"tagName": "div"}) -) - -test_boolean_prop = make_html_dialect_test( - ('html(f"
")', {}, {"tagName": "div", "attributes": {"foo": True}}), - ("""html(f"
")""", {}, {"tagName": "div", "attributes": {"foo": True}}), - ("""html(f'
')""", {}, {"tagName": "div", "attributes": {"foo": True}}), -) - -test_value_prop_name = make_html_dialect_test( - ('html(f"
")', {}, {"tagName": "div", "attributes": {"foo": "1"}}), - ( - """html(f'
')""", - {}, - {"tagName": "div", "attributes": {"foo": "1"}}, - ), - ( - """html(f"
")""", - {}, - {"tagName": "div", "attributes": {"foo": "1"}}, - ), - ( - """html(f"
")""", - {}, - {"tagName": "div", "attributes": {"foo": "1"}}, - ), - ( - """html(f'
')""", - {}, - {"tagName": "div", "attributes": {"foo": "1"}}, - ), -) - -test_expression_prop_value = make_html_dialect_test( - ( - """html(f"
")""", - {"a": 1.23}, - {"tagName": "div", "attributes": {"foo": 1.23}}, - ), - ( - """html(f'
')""", - {"a": 1.23}, - {"tagName": "div", "attributes": {"foo": 1.23}}, - ), - ( - """html(f"
")""", - {"a": 1.23}, - {"tagName": "div", "attributes": {"foo": 1.23}}, - ), - ( - """html(f"
")""", - {"a": 1.23}, - {"tagName": "div", "attributes": {"foo": "1.2"}}, - ), -) - -test_concatenated_prop_value = make_html_dialect_test( - ( - """html(f"
")""", - {"a": "1"}, - {"tagName": "div", "attributes": {"foo": "12"}}, - ), - ( - """html(f"
")""", - {"a": "1"}, - {"tagName": "div", "attributes": {"foo": "0/1/2"}}, - ), -) - - -test_slash_in_prop_value = make_html_dialect_test( - ( - """html(f"
")""", - {}, - {"tagName": "div", "attributes": {"foo": "/bar/quux"}}, - ) -) - - -test_spread = make_html_dialect_test( - ( - """html(f"
")""", - {"foo": {"foo": 1}}, - {"tagName": "div", "attributes": {"foo": 1, "bar": 2}}, - ) -) - - -test_comments = make_html_dialect_test( - ( - '''html( - f""" -
- before - - after -
- """ - )''', - {}, - {"tagName": "div", "children": ["before", "after"]}, - ), - ( - """html(f"
slight deviation from HTML comments<-->
")""", - {}, - {"tagName": "div"}, - ), -) - - -test_component = make_html_dialect_test( - ( - 'html(f"<{MyComponentWithChildren}>hello")', - {"MyComponentWithChildren": lambda children: html.div(children + ["world"])}, - {"tagName": "div", "children": ["hello", "world"]}, - ), - ( - 'html(f"<{MyComponentWithAttributes} x=2 y=3 />")', - { - "MyComponentWithAttributes": lambda x, y: html.div( - {"x": int(x) * 2, "y": int(y) * 2} - ) - }, - {"tagName": "div", "attributes": {"x": 4, "y": 6}}, - ), - ( - 'html(f"<{MyComponentWithAttributesAndChildren} x=2 y=3>hello")', - { - "MyComponentWithAttributesAndChildren": lambda x, y, children: html.div( - {"x": int(x) * 2, "y": int(y) * 2}, children + ["world"] - ) - }, - { - "tagName": "div", - "attributes": {"x": 4, "y": 6}, - "children": ["hello", "world"], - }, - ), -) - - -def test_tag_errors(): - with pytest.raises(DialectError, match="no token found"): - apply_dialects('html(f"< >")', "html") - with pytest.raises(DialectError, match="no token found"): - apply_dialects('html(f"<>")', "html") - with pytest.raises(DialectError, match="no token found"): - apply_dialects("""html(f"<'")""", "html") - with pytest.raises(DialectError, match="unexpected end of data"): - apply_dialects('html(f"<")', "html") - - -def test_attribute_name_errors(): - with pytest.raises(DialectError, match="expression not allowed"): - apply_dialects('html(f"
")', "html") - with pytest.raises(DialectError, match="unexpected end of data"): - apply_dialects('html(f"
")', "html") diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common/test_per_client_state.py index 197768de5..65639df01 100644 --- a/tests/test_server/test_common/test_per_client_state.py +++ b/tests/test_server/test_common/test_per_client_state.py @@ -65,7 +65,8 @@ def Counter(): client_counter.click() -def test_installed_module(driver, display): - victory = idom.install("victory@35.4.0") - display(victory.VictoryBar) +def test_module_from_template(driver, display): + victory = idom.web.module_from_template("react", "victory@35.4.0") + VictoryBar = idom.web.export(victory, "VictoryBar") + display(VictoryBar) driver.find_element_by_class_name("VictoryContainer") diff --git a/src/idom/client/__init__.py b/tests/test_web/__init__.py similarity index 100% rename from src/idom/client/__init__.py rename to tests/test_web/__init__.py diff --git a/tests/test_client/js/set-flag-when-unmount-is-called.js b/tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js similarity index 100% rename from tests/test_client/js/set-flag-when-unmount-is-called.js rename to tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js diff --git a/tests/test_client/js/simple-button.js b/tests/test_web/js_fixtures/simple-button.js similarity index 100% rename from tests/test_client/js/simple-button.js rename to tests/test_web/js_fixtures/simple-button.js diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py new file mode 100644 index 000000000..e4e30ad8a --- /dev/null +++ b/tests/test_web/test_module.py @@ -0,0 +1,39 @@ +from pathlib import Path + +import idom + + +JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" + + +def test_that_js_module_unmount_is_called(driver, driver_wait, display): + SomeComponent = idom.web.export( + idom.web.module_from_file( + "set-flag-when-unmount-is-called", + JS_FIXTURES_DIR / "set-flag-when-unmount-is-called.js", + ), + "SomeComponent", + ) + + set_current_component = idom.Ref(None) + + @idom.component + def ShowCurrentComponent(): + current_component, set_current_component.current = idom.hooks.use_state( + lambda: SomeComponent({"id": "some-component", "text": "initial component"}) + ) + return current_component + + display(ShowCurrentComponent) + + driver.find_element_by_id("some-component") + + set_current_component.current( + idom.html.h1({"id": "some-other-component"}, "some other component") + ) + + # the new component has been displayed + driver.find_element_by_id("some-other-component") + + # the unmount callback for the old component was called + driver.find_element_by_id("unmount-flag") diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py new file mode 100644 index 000000000..097143eae --- /dev/null +++ b/tests/test_web/test_utils.py @@ -0,0 +1,38 @@ +import pytest + +from idom.web.utils import resolve_module_exports_from_source +from tests.general_utils import assert_same_items + + +@pytest.mark.parametrize( + "module_source, expected_names, expected_references", + [ + ( + "asdfasdfasdf;export{one as One, two as Two};asdfasdf;", + ["One", "Two"], + [], + ), + ( + "asd;export{one as One};asdfasdf;export{two as Two};", + ["One", "Two"], + [], + ), + ("asdasd;export default something;", ["default"], []), + ( + "asdasd;export One something;asdfa;export Two somethingElse;", + ["One", "Two"], + [], + ), + ( + "asdasd;export One something;asdfa;export{two as Two};asdfasdf;", + ["One", "Two"], + [], + ), + ], +) +def test_resolve_module_exports_from_source( + module_source, expected_names, expected_references +): + names, references = resolve_module_exports_from_source(module_source) + assert_same_items(names, expected_names) + assert_same_items(references, expected_references) From cc04d0fac4111cd8a9ee3c7f5faa79556fe6b5ec Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 01:15:13 -0700 Subject: [PATCH 02/20] improve export capture logic --- src/idom/web/utils.py | 64 +++++++++++++------- tests/test_web/js_fixtures/exports_syntax.js | 23 +++++++ tests/test_web/test_utils.py | 60 +++++++++--------- 3 files changed, 96 insertions(+), 51 deletions(-) create mode 100644 tests/test_web/js_fixtures/exports_syntax.js diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 03f9354bd..1f1a270bb 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -50,15 +50,46 @@ def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str]]: names: Set[str] = set() - for match in _JS_MODULE_EXPORT_PATTERN.findall(content): - for export in match.split(","): - export_parts = export.split(" as ", 1) - names.add(export_parts[-1].strip()) - names.update(_JS_MODULE_EXPORT_FUNC_PATTERN.findall(content)) - names.update(_JS_MODULE_EXPORT_NAME_PATTERN.findall(content)) - - references: Set[str] = set(_JS_MODULE_EXPORT_FROM_REF_PATTERN.findall(content)) - return names, references + references: Set[str] = set() + for export in _JS_EXPORT_PATTERN.findall( + # strip comments + _JS_LINE_COMMENT.sub("", content) + ): + export = export.rstrip(";").strip() + # Exporting individual features + if export.startswith("let "): + names.update(let.split("=", 1)[0] for let in export[4:].split(",")) + elif export.startswith("function "): + names.add(export[9:].split("(", 1)[0]) + elif export.startswith("class "): + names.add(export[6:].split("{", 1)[0]) + # Renaming exports and export list + elif export.startswith("{") and export.endswith("}"): + names.update( + item.split(" as ", 1)[-1] for item in export.strip("{}").split(",") + ) + # Exporting destructured assignments with renaming + elif export.startswith("const "): + names.update( + item.split(":", 1)[0] + for item in export[6:].split("=", 1)[0].strip("{}").split(",") + ) + # Default exports + elif export.startswith("default "): + names.add("default") + # Aggregating modules + elif export.startswith("* as "): + names.add(export[5:].split(" from ", 1)[0]) + elif export.startswith("* "): + references.add(export[2:].split("from ", 1)[-1].strip("'\"")) + elif export.startswith("{") and " from " in export: + names.update( + item.split(" as ", 1)[-1] + for item in export.split(" from ")[0].strip("{}").split(",") + ) + else: + logger.warning(f"Unknown export type {export!r}") + return {n.strip() for n in names}, {r.strip() for r in references} def _resolve_relative_file_path(base_path: Path, rel_url: str) -> Path: @@ -81,16 +112,5 @@ def _resolve_relative_url(base_url: str, rel_url: str) -> str: return f"{base_url}/{rel_url}" -_JS_MODULE_EXPORT_PATTERN = re.compile( - r";?\s*export\s*{([0-9a-zA-Z_$\s,]*)}\s*;", re.MULTILINE -) -_JS_VAR = r"[a-zA-Z_$][0-9a-zA-Z_$]*" -_JS_MODULE_EXPORT_NAME_PATTERN = re.compile( - fr";?\s*export\s+({_JS_VAR})\s+{_JS_VAR}\s*;", re.MULTILINE -) -_JS_MODULE_EXPORT_FUNC_PATTERN = re.compile( - fr";?\s*export\s+function\s+({_JS_VAR})\s*\(.*?", re.MULTILINE -) -_JS_MODULE_EXPORT_FROM_REF_PATTERN = re.compile( - r""";?\s*export\s+\*\s+from\s+['"](.*?)['"];""" -) +_JS_LINE_COMMENT = re.compile(r"//.*$") +_JS_EXPORT_PATTERN = re.compile(r";?\s*export(?=\s+|{)(.*?(?:;|}\s*))", re.MULTILINE) diff --git a/tests/test_web/js_fixtures/exports_syntax.js b/tests/test_web/js_fixtures/exports_syntax.js new file mode 100644 index 000000000..0eec001be --- /dev/null +++ b/tests/test_web/js_fixtures/exports_syntax.js @@ -0,0 +1,23 @@ +// Copied from: https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export + +// Exporting individual features +export let name1, name2, name3; // also var, const +export let name4 = 4, name5 = 5, name6; // also var, const +export function functionName(){...} +export class ClassName {...} + +// Export list +export { name7, name8, name9 }; + +// Renaming exports +export { variable1 as name10, variable2 as name11, name12 }; + +// Exporting destructured assignments with renaming +export const { name13, name14: bar } = o; + +// Aggregating modules +export * from "source1"; // does not set the default export +export * from "source2"; // does not set the default export +export * as name15 from "source3"; // Draft ECMAScript® 2O21 +export { name16, name17 } from "source4"; +export { import1 as name18, import2 as name19, name20 } from "source5"; diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 097143eae..7942d1e94 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -1,38 +1,40 @@ +from pathlib import Path + import pytest from idom.web.utils import resolve_module_exports_from_source -from tests.general_utils import assert_same_items + + +JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @pytest.mark.parametrize( - "module_source, expected_names, expected_references", + "text", [ - ( - "asdfasdfasdf;export{one as One, two as Two};asdfasdf;", - ["One", "Two"], - [], - ), - ( - "asd;export{one as One};asdfasdf;export{two as Two};", - ["One", "Two"], - [], - ), - ("asdasd;export default something;", ["default"], []), - ( - "asdasd;export One something;asdfa;export Two somethingElse;", - ["One", "Two"], - [], - ), - ( - "asdasd;export One something;asdfa;export{two as Two};asdfasdf;", - ["One", "Two"], - [], - ), + "export default expression;", + "export default function (…) { … } // also class, function*", + "export default function name1(…) { … } // also class, function*", + "export { something as default };", + "export { default } from 'some-source';", + "export { something as default } from 'some-source';", ], ) -def test_resolve_module_exports_from_source( - module_source, expected_names, expected_references -): - names, references = resolve_module_exports_from_source(module_source) - assert_same_items(names, expected_names) - assert_same_items(references, expected_references) +def test_resolve_module_default_exports_from_source(text): + names, references = resolve_module_exports_from_source(text) + assert names == {"default"} and not references + + +def test_resolve_module_exports_from_source(): + fixture_file = JS_FIXTURES_DIR / "exports_syntax.js" + names, references = resolve_module_exports_from_source(fixture_file.read_text()) + assert ( + names + == ( + {f"name{i}" for i in range(1, 21)} + | { + "functionName", + "ClassName", + } + ) + and references == {"source1", "source2"} + ) From 8b56dd7fec90fcff8c914ca399394a29df72fece Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 22:12:06 -0700 Subject: [PATCH 03/20] update test-javascript working directory --- .github/workflows/test.yml | 2 +- noxfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd5082c40..1234d6ff8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: npm install -g npm@latest npm --version - name: Test Javascript - working-directory: ./src/idom/client/app + working-directory: ./src/idom/client run: | npm install npm test diff --git a/noxfile.py b/noxfile.py index d77a6779a..f5c2348c6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -122,7 +122,7 @@ def test_suite(session: Session) -> None: posargs += ["--cov=src/idom", "--cov-report", "term"] install_idom_dev(session, extras="all") - session.run("pytest", "tests", *posargs) + session.run("pytest", *posargs) @nox.session From 30eec528e293bf4bc5424826a69dd53af488c767 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 22:14:43 -0700 Subject: [PATCH 04/20] update doc --- src/idom/widgets/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/idom/widgets/utils.py b/src/idom/widgets/utils.py index d61d73fcc..410c6e197 100644 --- a/src/idom/widgets/utils.py +++ b/src/idom/widgets/utils.py @@ -1,6 +1,6 @@ """ -Widget Tools -============ +Widgets +======= """ from __future__ import annotations From 4fcb2256b02362b06a49d32ec50aed99b4b880b9 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 22:43:39 -0700 Subject: [PATCH 05/20] re-organize widgets directory --- docs/main.py | 6 +- docs/source/auto/api-reference.rst | 4 +- docs/source/examples.rst | 2 +- scripts/one_example.py | 2 +- src/idom/__init__.py | 5 +- src/idom/core/vdom.py | 4 +- src/idom/html.py | 218 ++++++++++++++++++ src/idom/server/prefab.py | 2 +- src/idom/web/module.py | 5 + src/idom/{widgets/utils.py => widgets.py} | 62 ++++- src/idom/widgets/__init__.py | 12 - src/idom/widgets/html.py | 209 ----------------- .../test_html.py => test_widgets.py} | 66 +++++- tests/test_widgets/__init__.py | 0 tests/test_widgets/test_utils.py | 56 ----- 15 files changed, 353 insertions(+), 300 deletions(-) create mode 100644 src/idom/html.py rename src/idom/{widgets/utils.py => widgets.py} (78%) delete mode 100644 src/idom/widgets/__init__.py delete mode 100644 src/idom/widgets/html.py rename tests/{test_widgets/test_html.py => test_widgets.py} (61%) delete mode 100644 tests/test_widgets/__init__.py delete mode 100644 tests/test_widgets/test_utils.py diff --git a/docs/main.py b/docs/main.py index 062ec5e7e..20dfd7014 100644 --- a/docs/main.py +++ b/docs/main.py @@ -6,9 +6,9 @@ from sanic import Sanic, response import idom -from idom.client.manage import web_modules_dir +from idom.config import IDOM_WED_MODULES_DIR from idom.server.sanic import PerClientStateServer -from idom.widgets.utils import multiview +from idom.widgets import multiview HERE = Path(__file__).parent @@ -18,7 +18,7 @@ def make_app(): app = Sanic(__name__) app.static("/docs", str(HERE / "build")) - app.static("/_modules", str(web_modules_dir())) + app.static("/_modules", str(IDOM_WED_MODULES_DIR.current)) @app.route("/") async def forward_to_index(request): diff --git a/docs/source/auto/api-reference.rst b/docs/source/auto/api-reference.rst index 7b71e8846..76645f7b7 100644 --- a/docs/source/auto/api-reference.rst +++ b/docs/source/auto/api-reference.rst @@ -55,10 +55,10 @@ API Reference .. automodule:: idom.utils :members: -.. automodule:: idom.widgets.html +.. automodule:: idom.html :members: -.. automodule:: idom.widgets.utils +.. automodule:: idom.widgets :members: Misc Modules diff --git a/docs/source/examples.rst b/docs/source/examples.rst index c9ab6365b..9afabb913 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -59,7 +59,7 @@ Simply install your javascript library of choice using the ``idom`` CLI: idom install victory -Then import the module with :class:`~idom.widgets.utils.Module`: +Then import the module with :mod:`~idom.web.module`: .. example:: victory_chart diff --git a/scripts/one_example.py b/scripts/one_example.py index ac51693c6..f5ae42f28 100644 --- a/scripts/one_example.py +++ b/scripts/one_example.py @@ -5,7 +5,7 @@ from threading import Thread import idom -from idom.widgets.utils import hotswap +from idom.widgets import hotswap here = Path(__file__).parent diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 8074adff1..71efeaf1e 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -10,7 +10,7 @@ __author__ = "idom-team" -from . import config, log, web +from . import config, html, log, web from .core import hooks from .core.component import Component, component from .core.events import Events, event @@ -18,8 +18,7 @@ from .core.vdom import VdomDict, vdom from .server.prefab import run from .utils import Ref, html_to_vdom -from .widgets.html import html -from .widgets.utils import hotswap, multiview +from .widgets import hotswap, multiview __all__ = [ diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index d542954e5..166c4ddd9 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -106,7 +106,7 @@ class _VdomDictRequired(TypedDict, total=True): class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A VDOM dictionary""" + """A VDOM dictionary - see :ref:`VDOM Mimetype` for more info""" _AttributesAndChildrenArg = Union[Mapping[str, Any], str, Iterable[Any], Any] @@ -201,7 +201,7 @@ def constructor( qualname_prefix = constructor.__qualname__.rsplit(".", 1)[0] constructor.__qualname__ = qualname_prefix + f".{tag}" constructor.__doc__ = ( - f"""Create a new ``<{tag}/>`` - returns :ref:`VDOM `.""" + f"""Create a new ``<{tag}/>`` - returns a :class:`VdomDict`.""" ) return constructor diff --git a/src/idom/html.py b/src/idom/html.py new file mode 100644 index 000000000..b357fafec --- /dev/null +++ b/src/idom/html.py @@ -0,0 +1,218 @@ +""" +Standard HTML Elements +====================== + + +External sources +---------------- + +link = make_vdom_constructor("link", allow_children=False) + + +Content Sectioning +------------------ + +- :func:`style` +- :func:`address` +- :func:`article` +- :func:`aside` +- :func:`footer` +- :func:`h1` +- :func:`h2` +- :func:`h3` +- :func:`h4` +- :func:`h5` +- :func:`h6` +- :func:`header` +- :func:`hgroup` +- :func:`nav` +- :func:`section` + + +Text Content +------------ +- :func:`blockquote` +- :func:`dd` +- :func:`div` +- :func:`dl` +- :func:`dt` +- :func:`figcaption` +- :func:`figure` +- :func:`hr` +- :func:`li` +- :func:`ol` +- :func:`p` +- :func:`pre` +- :func:`ul` + + +Inline Text Semantics +--------------------- + +- :func:`a` +- :func:`abbr` +- :func:`b` +- :func:`br` +- :func:`cite` +- :func:`code` +- :func:`data` +- :func:`em` +- :func:`i` +- :func:`kbd` +- :func:`mark` +- :func:`q` +- :func:`s` +- :func:`samp` +- :func:`small` +- :func:`span` +- :func:`strong` +- :func:`sub` +- :func:`sup` +- :func:`time` +- :func:`u` +- :func:`var` + + +Image and video +--------------- + +- :func:`img` +- :func:`audio` +- :func:`video` +- :func:`source` + + +Table Content +------------- + +- :func:`caption` +- :func:`col` +- :func:`colgroup` +- :func:`table` +- :func:`tbody` +- :func:`td` +- :func:`tfoot` +- :func:`th` +- :func:`thead` +- :func:`tr` + + +Forms +----- + +- :func:`meter` +- :func:`output` +- :func:`progress` +- :func:`input` +- :func:`button` +- :func:`label` +- :func:`fieldset` +- :func:`legend` + + +Interactive Elements +-------------------- + +- :func:`details` +- :func:`dialog` +- :func:`menu` +- :func:`menuitem` +- :func:`summary` +""" + +from .core.vdom import make_vdom_constructor + + +# External sources +link = make_vdom_constructor("link", allow_children=False) + +# Content sectioning +style = make_vdom_constructor("style") +address = make_vdom_constructor("address") +article = make_vdom_constructor("article") +aside = make_vdom_constructor("aside") +footer = make_vdom_constructor("footer") +h1 = make_vdom_constructor("h1") +h2 = make_vdom_constructor("h2") +h3 = make_vdom_constructor("h3") +h4 = make_vdom_constructor("h4") +h5 = make_vdom_constructor("h5") +h6 = make_vdom_constructor("h6") +header = make_vdom_constructor("header") +hgroup = make_vdom_constructor("hgroup") +nav = make_vdom_constructor("nav") +section = make_vdom_constructor("section") + +# Text content +blockquote = make_vdom_constructor("blockquote") +dd = make_vdom_constructor("dd") +div = make_vdom_constructor("div") +dl = make_vdom_constructor("dl") +dt = make_vdom_constructor("dt") +figcaption = make_vdom_constructor("figcaption") +figure = make_vdom_constructor("figure") +hr = make_vdom_constructor("hr", allow_children=False) +li = make_vdom_constructor("li") +ol = make_vdom_constructor("ol") +p = make_vdom_constructor("p") +pre = make_vdom_constructor("pre") +ul = make_vdom_constructor("ul") + +# Inline text semantics +a = make_vdom_constructor("a") +abbr = make_vdom_constructor("abbr") +b = make_vdom_constructor("b") +br = make_vdom_constructor("br", allow_children=False) +cite = make_vdom_constructor("cite") +code = make_vdom_constructor("code") +data = make_vdom_constructor("data") +em = make_vdom_constructor("em") +i = make_vdom_constructor("i") +kbd = make_vdom_constructor("kbd") +mark = make_vdom_constructor("mark") +q = make_vdom_constructor("q") +s = make_vdom_constructor("s") +samp = make_vdom_constructor("samp") +small = make_vdom_constructor("small") +span = make_vdom_constructor("span") +strong = make_vdom_constructor("strong") +sub = make_vdom_constructor("sub") +sup = make_vdom_constructor("sup") +time = make_vdom_constructor("time") +u = make_vdom_constructor("u") +var = make_vdom_constructor("var") + +# Image and video +img = make_vdom_constructor("img", allow_children=False) +audio = make_vdom_constructor("audio") +video = make_vdom_constructor("video") +source = make_vdom_constructor("source", allow_children=False) + +# Table content +caption = make_vdom_constructor("caption") +col = make_vdom_constructor("col") +colgroup = make_vdom_constructor("colgroup") +table = make_vdom_constructor("table") +tbody = make_vdom_constructor("tbody") +td = make_vdom_constructor("td") +tfoot = make_vdom_constructor("tfoot") +th = make_vdom_constructor("th") +thead = make_vdom_constructor("thead") +tr = make_vdom_constructor("tr") + +# Forms +meter = make_vdom_constructor("meter") +output = make_vdom_constructor("output") +progress = make_vdom_constructor("progress") +input = make_vdom_constructor("input", allow_children=False) +button = make_vdom_constructor("button") +label = make_vdom_constructor("label") +fieldset = make_vdom_constructor("fieldset") +legend = make_vdom_constructor("legend") + +# Interactive elements +details = make_vdom_constructor("details") +dialog = make_vdom_constructor("dialog") +menu = make_vdom_constructor("menu") +menuitem = make_vdom_constructor("menuitem") +summary = make_vdom_constructor("summary") diff --git a/src/idom/server/prefab.py b/src/idom/server/prefab.py index cc9adfca0..f11616611 100644 --- a/src/idom/server/prefab.py +++ b/src/idom/server/prefab.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional, Tuple, TypeVar from idom.core.component import ComponentConstructor -from idom.widgets.utils import MountFunc, MultiViewMount, hotswap, multiview +from idom.widgets import MountFunc, MultiViewMount, hotswap, multiview from .proto import Server, ServerFactory from .utils import find_available_port, find_builtin_server_type diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 9e1a9311d..f0ddd3523 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -1,3 +1,8 @@ +""" +Web Modules +=========== +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/src/idom/widgets/utils.py b/src/idom/widgets.py similarity index 78% rename from src/idom/widgets/utils.py rename to src/idom/widgets.py index 410c6e197..fe3922477 100644 --- a/src/idom/widgets/utils.py +++ b/src/idom/widgets.py @@ -4,11 +4,65 @@ """ from __future__ import annotations -from typing import Any, Callable, Dict, Optional, Set, Tuple, TypeVar +from base64 import b64encode +from typing import Any, Callable, Dict, Optional, Set, Tuple, TypeVar, Union -from idom.core import hooks -from idom.core.component import ComponentConstructor, component -from idom.utils import Ref +import idom + +from . import html +from .core import hooks +from .core.component import ComponentConstructor, component +from .core.vdom import VdomDict +from .utils import Ref + + +def image( + format: str, + value: Union[str, bytes] = "", + attributes: Optional[Dict[str, Any]] = None, +) -> VdomDict: + """Utility for constructing an image from a string or bytes + + The source value will automatically be encoded to base64 + """ + if format == "svg": + format = "svg+xml" + + if isinstance(value, str): + bytes_value = value.encode() + else: + bytes_value = value + + base64_value = b64encode(bytes_value).decode() + src = f"data:image/{format};base64,{base64_value}" + + return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} + + +@component +def Input( + callback: Callable[[str], None], + type: str, + value: str = "", + attributes: Optional[Dict[str, Any]] = None, + cast: Optional[Callable[[str], Any]] = None, + ignore_empty: bool = True, +) -> VdomDict: + """Utility for making an ```` with a callback""" + attrs = attributes or {} + value, set_value = idom.hooks.use_state(value) + + events = idom.Events() + + @events.on("change") + def on_change(event: Dict[str, Any]) -> None: + value = event["value"] + set_value(value) + if not value and ignore_empty: + return + callback(value if cast is None else cast(value)) + + return html.input({"type": type, "value": value, **attrs}, event_handlers=events) MountFunc = Callable[[ComponentConstructor], None] diff --git a/src/idom/widgets/__init__.py b/src/idom/widgets/__init__.py deleted file mode 100644 index dbb88d0d9..000000000 --- a/src/idom/widgets/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .html import Input, html, image -from .utils import hotswap, multiview - - -__all__ = [ - "node", - "hotswap", - "multiview", - "html", - "image", - "Input", -] diff --git a/src/idom/widgets/html.py b/src/idom/widgets/html.py deleted file mode 100644 index 3f72c62ef..000000000 --- a/src/idom/widgets/html.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -HTML Widgets -============ -""" - -from base64 import b64encode -from typing import Any, Callable, Dict, Optional, Union, overload - -import idom -from idom.core.component import AbstractComponent, ComponentConstructor, component -from idom.core.vdom import ( - VdomDict, - VdomDictConstructor, - coalesce_attributes_and_children, - make_vdom_constructor, - vdom, -) - - -def image( - format: str, - value: Union[str, bytes] = "", - attributes: Optional[Dict[str, Any]] = None, -) -> VdomDict: - """Utility for constructing an image from a string or bytes - - The source value will automatically be encoded to base64 - """ - if format == "svg": - format = "svg+xml" - - if isinstance(value, str): - bytes_value = value.encode() - else: - bytes_value = value - - base64_value = b64encode(bytes_value).decode() - src = f"data:image/{format};base64,{base64_value}" - - return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} - - -@component -def Input( - callback: Callable[[str], None], - type: str, - value: str = "", - attributes: Optional[Dict[str, Any]] = None, - cast: Optional[Callable[[str], Any]] = None, - ignore_empty: bool = True, -) -> VdomDict: - """Utility for making an ```` with a callback""" - attrs = attributes or {} - value, set_value = idom.hooks.use_state(value) - - events = idom.Events() - - @events.on("change") - def on_change(event: Dict[str, Any]) -> None: - value = event["value"] - set_value(value) - if not value and ignore_empty: - return - callback(value if cast is None else cast(value)) - - return html.input({"type": type, "value": value, **attrs}, event_handlers=events) - - -class Html: - """Utility for making basic HTML elements - - Many basic elements already have constructors, however accessing an attribute - of any name on this object will return a constructor for an element with the - same ``tagName``. - - All constructors return :class:`~idom.core.vdom.VdomDict`. - """ - - def __init__(self) -> None: - # External sources - self.link = make_vdom_constructor("link", allow_children=False) - - # Content sectioning - self.style = make_vdom_constructor("style") - self.address = make_vdom_constructor("address") - self.article = make_vdom_constructor("article") - self.aside = make_vdom_constructor("aside") - self.footer = make_vdom_constructor("footer") - self.h1 = make_vdom_constructor("h1") - self.h2 = make_vdom_constructor("h2") - self.h3 = make_vdom_constructor("h3") - self.h4 = make_vdom_constructor("h4") - self.h5 = make_vdom_constructor("h5") - self.h6 = make_vdom_constructor("h6") - self.header = make_vdom_constructor("header") - self.hgroup = make_vdom_constructor("hgroup") - self.nav = make_vdom_constructor("nav") - self.section = make_vdom_constructor("section") - - # Text content - self.blockquote = make_vdom_constructor("blockquote") - self.dd = make_vdom_constructor("dd") - self.div = make_vdom_constructor("div") - self.dl = make_vdom_constructor("dl") - self.dt = make_vdom_constructor("dt") - self.figcaption = make_vdom_constructor("figcaption") - self.figure = make_vdom_constructor("figure") - self.hr = make_vdom_constructor("hr", allow_children=False) - self.li = make_vdom_constructor("li") - self.ol = make_vdom_constructor("ol") - self.p = make_vdom_constructor("p") - self.pre = make_vdom_constructor("pre") - self.ul = make_vdom_constructor("ul") - - # Inline text semantics - self.a = make_vdom_constructor("a") - self.abbr = make_vdom_constructor("abbr") - self.b = make_vdom_constructor("b") - self.br = make_vdom_constructor("br", allow_children=False) - self.cite = make_vdom_constructor("cite") - self.code = make_vdom_constructor("code") - self.data = make_vdom_constructor("data") - self.em = make_vdom_constructor("em") - self.i = make_vdom_constructor("i") - self.kbd = make_vdom_constructor("kbd") - self.mark = make_vdom_constructor("mark") - self.q = make_vdom_constructor("q") - self.s = make_vdom_constructor("s") - self.samp = make_vdom_constructor("samp") - self.small = make_vdom_constructor("small") - self.span = make_vdom_constructor("span") - self.strong = make_vdom_constructor("strong") - self.sub = make_vdom_constructor("sub") - self.sup = make_vdom_constructor("sup") - self.time = make_vdom_constructor("time") - self.u = make_vdom_constructor("u") - self.var = make_vdom_constructor("var") - - # Image and video - self.img = make_vdom_constructor("img", allow_children=False) - self.audio = make_vdom_constructor("audio") - self.video = make_vdom_constructor("video") - self.source = make_vdom_constructor("source", allow_children=False) - - # Table content - self.caption = make_vdom_constructor("caption") - self.col = make_vdom_constructor("col") - self.colgroup = make_vdom_constructor("colgroup") - self.table = make_vdom_constructor("table") - self.tbody = make_vdom_constructor("tbody") - self.td = make_vdom_constructor("td") - self.tfoot = make_vdom_constructor("tfoot") - self.th = make_vdom_constructor("th") - self.thead = make_vdom_constructor("thead") - self.tr = make_vdom_constructor("tr") - - # Forms - self.meter = make_vdom_constructor("meter") - self.output = make_vdom_constructor("output") - self.progress = make_vdom_constructor("progress") - self.input = make_vdom_constructor("input", allow_children=False) - self.button = make_vdom_constructor("button") - self.label = make_vdom_constructor("label") - self.fieldset = make_vdom_constructor("fieldset") - self.legend = make_vdom_constructor("legend") - - # Interactive elements - self.details = make_vdom_constructor("details") - self.dialog = make_vdom_constructor("dialog") - self.menu = make_vdom_constructor("menu") - self.menuitem = make_vdom_constructor("menuitem") - self.summary = make_vdom_constructor("summary") - - @overload - @staticmethod - def __call__( - tag: ComponentConstructor, *attributes_and_children: Any - ) -> AbstractComponent: - ... - - @overload - @staticmethod - def __call__(tag: str, *attributes_and_children: Any) -> VdomDict: - ... - - @staticmethod - def __call__( - tag: Union[str, ComponentConstructor], - *attributes_and_children: Any, - ) -> Union[VdomDict, AbstractComponent]: - if isinstance(tag, str): - return vdom(tag, *attributes_and_children) - - attributes, children = coalesce_attributes_and_children(attributes_and_children) - - if children: - return tag(children=children, **attributes) - else: - return tag(**attributes) - - def __getattr__(self, tag: str) -> VdomDictConstructor: - return make_vdom_constructor(tag) - - -html = Html() -"""Holds pre-made constructors for basic HTML elements - -See :class:`Html` for more info. -""" diff --git a/tests/test_widgets/test_html.py b/tests/test_widgets.py similarity index 61% rename from tests/test_widgets/test_html.py rename to tests/test_widgets.py index d93567943..ca0b97090 100644 --- a/tests/test_widgets/test_html.py +++ b/tests/test_widgets.py @@ -1,5 +1,6 @@ import time from base64 import b64encode +from pathlib import Path from selenium.webdriver.common.keys import Keys @@ -7,26 +8,79 @@ from tests.driver_utils import send_keys -_image_src_bytes = b""" +HERE = Path(__file__).parent + + +def test_multiview_repr(): + assert str(idom.widgets.MultiViewMount({})) == "MultiViewMount({})" + + +def test_hostwap_update_on_change(driver, display): + """Ensure shared hotswapping works + + This basically means that previously rendered views of a hotswap component get updated + when a new view is mounted, not just the next time it is re-displayed + + In this test we construct a scenario where clicking a button will cause a pre-existing + hotswap component to be updated + """ + + def make_next_count_constructor(count): + """We need to construct a new function so they're different when we set_state""" + + def constructor(): + count.current += 1 + return idom.html.div({"id": f"hotswap-{count.current}"}, count.current) + + return constructor + + @idom.component + def ButtonSwapsDivs(): + count = idom.Ref(0) + + @idom.event + async def on_click(event): + mount(make_next_count_constructor(count)) + + incr = idom.html.button({"onClick": on_click, "id": "incr-button"}, "incr") + + mount, make_hostswap = idom.widgets.hotswap(update_on_change=True) + mount(make_next_count_constructor(count)) + hotswap_view = make_hostswap() + + return idom.html.div(incr, hotswap_view) + + display(ButtonSwapsDivs) + + client_incr_button = driver.find_element_by_id("incr-button") + + driver.find_element_by_id("hotswap-1") + client_incr_button.click() + driver.find_element_by_id("hotswap-2") + client_incr_button.click() + driver.find_element_by_id("hotswap-3") + + +IMAGE_SRC_BYTES = b""" """ -_base64_image_src = b64encode(_image_src_bytes).decode() +BASE64_IMAGE_SRC = b64encode(IMAGE_SRC_BYTES).decode() def test_image_from_string(driver, display): - src = _image_src_bytes.decode() + src = IMAGE_SRC_BYTES.decode() display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) client_img = driver.find_element_by_id("a-circle-1") - assert _base64_image_src in client_img.get_attribute("src") + assert BASE64_IMAGE_SRC in client_img.get_attribute("src") def test_image_from_bytes(driver, display): - src = _image_src_bytes + src = IMAGE_SRC_BYTES display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) client_img = driver.find_element_by_id("a-circle-1") - assert _base64_image_src in client_img.get_attribute("src") + assert BASE64_IMAGE_SRC in client_img.get_attribute("src") def test_input_callback(driver, driver_wait, display): diff --git a/tests/test_widgets/__init__.py b/tests/test_widgets/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_widgets/test_utils.py b/tests/test_widgets/test_utils.py deleted file mode 100644 index c602fbc07..000000000 --- a/tests/test_widgets/test_utils.py +++ /dev/null @@ -1,56 +0,0 @@ -from pathlib import Path - -import idom - - -HERE = Path(__file__).parent - - -def test_multiview_repr(): - assert str(idom.widgets.utils.MultiViewMount({})) == "MultiViewMount({})" - - -def test_hostwap_update_on_change(driver, display): - """Ensure shared hotswapping works - - This basically means that previously rendered views of a hotswap component get updated - when a new view is mounted, not just the next time it is re-displayed - - In this test we construct a scenario where clicking a button will cause a pre-existing - hotswap component to be updated - """ - - def make_next_count_constructor(count): - """We need to construct a new function so they're different when we set_state""" - - def constructor(): - count.current += 1 - return idom.html.div({"id": f"hotswap-{count.current}"}, count.current) - - return constructor - - @idom.component - def ButtonSwapsDivs(): - count = idom.Ref(0) - - @idom.event - async def on_click(event): - mount(make_next_count_constructor(count)) - - incr = idom.html.button({"onClick": on_click, "id": "incr-button"}, "incr") - - mount, make_hostswap = idom.widgets.hotswap(update_on_change=True) - mount(make_next_count_constructor(count)) - hotswap_view = make_hostswap() - - return idom.html.div(incr, hotswap_view) - - display(ButtonSwapsDivs) - - client_incr_button = driver.find_element_by_id("incr-button") - - driver.find_element_by_id("hotswap-1") - client_incr_button.click() - driver.find_element_by_id("hotswap-2") - client_incr_button.click() - driver.find_element_by_id("hotswap-3") From dcdbb6676e47ac5bc358afaba15df26e898a0021 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 23:06:23 -0700 Subject: [PATCH 06/20] slightly rename files --- requirements/test-env.txt | 1 + .../js_fixtures/{exports_syntax.js => exports-syntax.js} | 0 tests/test_web/test_utils.py | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename tests/test_web/js_fixtures/{exports_syntax.js => exports-syntax.js} (100%) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 586c60948..6fd1686da 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -5,3 +5,4 @@ pytest-mock pytest-timeout selenium ipython +responses diff --git a/tests/test_web/js_fixtures/exports_syntax.js b/tests/test_web/js_fixtures/exports-syntax.js similarity index 100% rename from tests/test_web/js_fixtures/exports_syntax.js rename to tests/test_web/js_fixtures/exports-syntax.js diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 7942d1e94..66773918f 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -25,7 +25,7 @@ def test_resolve_module_default_exports_from_source(text): def test_resolve_module_exports_from_source(): - fixture_file = JS_FIXTURES_DIR / "exports_syntax.js" + fixture_file = JS_FIXTURES_DIR / "exports-syntax.js" names, references = resolve_module_exports_from_source(fixture_file.read_text()) assert ( names From ad870381aa6a16721b9d3e5f4b74efd3eb64feba Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 14 Jun 2021 23:59:34 -0700 Subject: [PATCH 07/20] add tests for export resolution --- src/idom/web/utils.py | 58 +++++++------ .../js_fixtures/export-resolution/index.js | 2 + .../js_fixtures/export-resolution/one.js | 2 + .../js_fixtures/export-resolution/two.js | 2 + tests/test_web/js_fixtures/exports-syntax.js | 10 +-- tests/test_web/test_utils.py | 81 ++++++++++++++++++- 6 files changed, 122 insertions(+), 33 deletions(-) create mode 100644 tests/test_web/js_fixtures/export-resolution/index.js create mode 100644 tests/test_web/js_fixtures/export-resolution/one.js create mode 100644 tests/test_web/js_fixtures/export-resolution/two.js diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 1f1a270bb..c4deaea92 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -18,43 +18,50 @@ def web_module_path(name: str) -> Path: def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: - export_names, references = resolve_module_exports_from_source(file.read_text()) if max_depth == 0: - logger.warning(f"Unable to resolve all exports for {file}") - else: - for ref in references: - if urlparse(ref).scheme: # is an absolute URL - export_names.update(resolve_module_exports_from_url(ref, max_depth - 1)) - elif ref.startswith("."): - path = _resolve_relative_file_path(file, ref) - export_names.update( - resolve_module_exports_from_file(path, max_depth - 1) - ) - else: - logger.warning(f"Did not resolve exports for unknown location {ref}") + logger.warning(f"Did not resolve all exports for {file} - max depth reached") + return set() + elif not file.exists(): + logger.warning(f"Did not resolve exports for unknown file {file}") + return set() + + export_names, references = resolve_module_exports_from_source(file.read_text()) + + for ref in references: + if urlparse(ref).scheme: # is an absolute URL + export_names.update(resolve_module_exports_from_url(ref, max_depth - 1)) + else: + path = _resolve_relative_file_path(file, ref) + export_names.update(resolve_module_exports_from_file(path, max_depth - 1)) + return export_names def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: - export_names, references = resolve_module_exports_from_source( - requests.get(url).text - ) if max_depth == 0: - logger.warning(f"Unable to fully resolve all exports for {url}") - else: - for ref in references: - url = _resolve_relative_url(url, ref) - export_names.update(resolve_module_exports_from_url(url, max_depth - 1)) + logger.warning(f"Did not resolve all exports for {url} - max depth reached") + return set() + + try: + text = requests.get(url).text + except requests.exceptions.ConnectionError as error: + reason = "" if error is None else " - {error.errno}" + logger.warning("Did not resolve exports for url " + url + reason) + return set() + + export_names, references = resolve_module_exports_from_source(text) + + for ref in references: + url = _resolve_relative_url(url, ref) + export_names.update(resolve_module_exports_from_url(url, max_depth - 1)) + return export_names def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str]]: names: Set[str] = set() references: Set[str] = set() - for export in _JS_EXPORT_PATTERN.findall( - # strip comments - _JS_LINE_COMMENT.sub("", content) - ): + for export in _JS_EXPORT_PATTERN.findall(content): export = export.rstrip(";").strip() # Exporting individual features if export.startswith("let "): @@ -112,5 +119,4 @@ def _resolve_relative_url(base_url: str, rel_url: str) -> str: return f"{base_url}/{rel_url}" -_JS_LINE_COMMENT = re.compile(r"//.*$") _JS_EXPORT_PATTERN = re.compile(r";?\s*export(?=\s+|{)(.*?(?:;|}\s*))", re.MULTILINE) diff --git a/tests/test_web/js_fixtures/export-resolution/index.js b/tests/test_web/js_fixtures/export-resolution/index.js new file mode 100644 index 000000000..2f1f46a51 --- /dev/null +++ b/tests/test_web/js_fixtures/export-resolution/index.js @@ -0,0 +1,2 @@ +export {index as Index}; +export * from "./one.js"; diff --git a/tests/test_web/js_fixtures/export-resolution/one.js b/tests/test_web/js_fixtures/export-resolution/one.js new file mode 100644 index 000000000..159b50564 --- /dev/null +++ b/tests/test_web/js_fixtures/export-resolution/one.js @@ -0,0 +1,2 @@ +export {one as One}; +export * from "./two.js"; diff --git a/tests/test_web/js_fixtures/export-resolution/two.js b/tests/test_web/js_fixtures/export-resolution/two.js new file mode 100644 index 000000000..4e1d807c2 --- /dev/null +++ b/tests/test_web/js_fixtures/export-resolution/two.js @@ -0,0 +1,2 @@ +export {two as Two}; +export * from "https://some.external.url"; diff --git a/tests/test_web/js_fixtures/exports-syntax.js b/tests/test_web/js_fixtures/exports-syntax.js index 0eec001be..8f9b0e612 100644 --- a/tests/test_web/js_fixtures/exports-syntax.js +++ b/tests/test_web/js_fixtures/exports-syntax.js @@ -16,8 +16,8 @@ export { variable1 as name10, variable2 as name11, name12 }; export const { name13, name14: bar } = o; // Aggregating modules -export * from "source1"; // does not set the default export -export * from "source2"; // does not set the default export -export * as name15 from "source3"; // Draft ECMAScript® 2O21 -export { name16, name17 } from "source4"; -export { import1 as name18, import2 as name19, name20 } from "source5"; +export * from "https://source1.com"; // does not set the default export +export * from "https://source2.com"; // does not set the default export +export * as name15 from "https://source3.com"; // Draft ECMAScript® 2O21 +export { name16, name17 } from "https://source4.com"; +export { import1 as name18, import2 as name19, name20 } from "https://source5.com"; diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 66773918f..202d4111b 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -1,13 +1,90 @@ from pathlib import Path import pytest +import responses -from idom.web.utils import resolve_module_exports_from_source +from idom.web.utils import ( + resolve_module_exports_from_file, + resolve_module_exports_from_source, + resolve_module_exports_from_url, +) JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" +@responses.activate +def test_resolve_module_exports_from_file(caplog): + responses.add( + responses.GET, + "https://some.external.url", + body="export {something as ExternalUrl}", + ) + path = JS_FIXTURES_DIR / "export-resolution" / "index.js" + assert resolve_module_exports_from_file(path, 4) == { + "Index", + "One", + "Two", + "ExternalUrl", + } + + +def test_resolve_module_exports_from_file_log_on_max_depth(caplog): + path = JS_FIXTURES_DIR / "export-resolution" / "index.js" + assert resolve_module_exports_from_file(path, 0) == set() + assert len(caplog.records) == 1 + assert caplog.records[0].message.endswith("max depth reached") + + caplog.records.clear() + + assert resolve_module_exports_from_file(path, 2) == {"Index", "One"} + assert len(caplog.records) == 1 + assert caplog.records[0].message.endswith("max depth reached") + + +def test_resolve_module_exports_from_file_log_on_unknown_file_location( + caplog, tmp_path +): + file = tmp_path / "some.js" + file.write_text("export * from './does-not-exist.js';") + resolve_module_exports_from_file(file, 2) + assert len(caplog.records) == 1 + assert caplog.records[0].message.startswith( + "Did not resolve exports for unknown file" + ) + + +@responses.activate +def test_resolve_module_exports_from_url(): + responses.add( + responses.GET, + "https://first.url", + body="export const First = 1; export * from 'https://second.url';", + ) + responses.add( + responses.GET, + "https://second.url", + body="export const Second = 2;", + ) + + assert resolve_module_exports_from_url("https://first.url", 2) == { + "First", + "Second", + } + + +def test_resolve_module_exports_from_url_log_on_max_depth(caplog): + assert resolve_module_exports_from_url("https://some.url", 0) == set() + assert len(caplog.records) == 1 + assert caplog.records[0].message.endswith("max depth reached") + + +def test_resolve_module_exports_from_url_log_on_bad_response(caplog): + assert resolve_module_exports_from_url("https://some.url", 1) == set() + assert len(caplog.records) == 1 + assert caplog.records[0].message.startswith("Did not resolve exports for url") + + @pytest.mark.parametrize( "text", [ @@ -36,5 +113,5 @@ def test_resolve_module_exports_from_source(): "ClassName", } ) - and references == {"source1", "source2"} + and references == {"https://source1.com", "https://source2.com"} ) From 63ebfce4ac4f4efcc1ef1168ad101a6ba5297caa Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 00:01:15 -0700 Subject: [PATCH 08/20] rename test_app to test_client --- tests/{test_app.py => test_client.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_app.py => test_client.py} (100%) diff --git a/tests/test_app.py b/tests/test_client.py similarity index 100% rename from tests/test_app.py rename to tests/test_client.py From e543f631fa9730a0945e07a9d10a9037d3f33e7e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 00:05:37 -0700 Subject: [PATCH 09/20] remove idom install from docs Dockerfile --- docs/Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 27ebad0ff..5c5781ab5 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -28,8 +28,6 @@ ADD README.md ./ RUN pip install -e .[all] -RUN python -m idom install htm victory semantic-ui-react @material-ui/core - # Build the Docs # -------------- ADD docs/main.py ./docs/ From a170397e91f66cd926ccaac7aa6e4a9c2bce0c83 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 00:07:35 -0700 Subject: [PATCH 10/20] update flake8 node_modules ignore pattern --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 5ac552c3a..1fbef95a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,7 @@ max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9,N,ROH exclude = - src/idom/client/app/node_modules/* + **/node_modules/* .eggs/* .tox/* From 6bddc8f9534c6574e0710d0e7f951fce9cf4cf4e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 00:40:36 -0700 Subject: [PATCH 11/20] fix export pattern matching --- src/idom/web/utils.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index c4deaea92..32a9bb8d3 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -61,15 +61,18 @@ def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str]]: names: Set[str] = set() references: Set[str] = set() - for export in _JS_EXPORT_PATTERN.findall(content): + + if _JS_DEFAULT_EXPORT_PATTERN.search(content): + names.add("default") + + # Exporting functions and classes + names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content)) + + for export in _JS_GENERAL_EXPORT_PATTERN.findall(content): export = export.rstrip(";").strip() # Exporting individual features if export.startswith("let "): names.update(let.split("=", 1)[0] for let in export[4:].split(",")) - elif export.startswith("function "): - names.add(export[9:].split("(", 1)[0]) - elif export.startswith("class "): - names.add(export[6:].split("{", 1)[0]) # Renaming exports and export list elif export.startswith("{") and export.endswith("}"): names.update( @@ -119,4 +122,12 @@ def _resolve_relative_url(base_url: str, rel_url: str) -> str: return f"{base_url}/{rel_url}" -_JS_EXPORT_PATTERN = re.compile(r";?\s*export(?=\s+|{)(.*?(?:;|}\s*))", re.MULTILINE) +_JS_DEFAULT_EXPORT_PATTERN = re.compile( + rf";?\s*export\s+default\s", +) +_JS_FUNC_OR_CLS_EXPORT_PATTERN = re.compile( + rf";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)" +) +_JS_GENERAL_EXPORT_PATTERN = re.compile( + r";?\s*export(?=\s+|{)(.*?)(?:;|$)", re.MULTILINE +) From b0828693f42573b7afc2da853661189e4b292652 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 09:42:55 -0700 Subject: [PATCH 12/20] fix styles --- docs/source/examples/super_simple_chart.py | 1 - setup.cfg | 2 +- src/idom/web/utils.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/source/examples/super_simple_chart.py b/docs/source/examples/super_simple_chart.py index ad3a748d3..8f5d77ae3 100644 --- a/docs/source/examples/super_simple_chart.py +++ b/docs/source/examples/super_simple_chart.py @@ -1,7 +1,6 @@ from pathlib import Path import idom -from idom.config import IDOM_WED_MODULES_DIR file = Path(__file__).parent / "super_simple_chart.js" diff --git a/setup.cfg b/setup.cfg index 1fbef95a9..5d1bb1235 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,7 +8,7 @@ warn_redundant_casts = True warn_unused_ignores = True [flake8] -ignore = E203, E266, E501, W503, F811, N802 +ignore = E203, E266, E501, W503, F811, N802, N806 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9,N,ROH diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 32a9bb8d3..cec97037d 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -123,10 +123,10 @@ def _resolve_relative_url(base_url: str, rel_url: str) -> str: _JS_DEFAULT_EXPORT_PATTERN = re.compile( - rf";?\s*export\s+default\s", + r";?\s*export\s+default\s", ) _JS_FUNC_OR_CLS_EXPORT_PATTERN = re.compile( - rf";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)" + r";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)" ) _JS_GENERAL_EXPORT_PATTERN = re.compile( r";?\s*export(?=\s+|{)(.*?)(?:;|$)", re.MULTILINE From bb654049fd4f07d84ef9ac99906a0d2a6a394e44 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 21:42:53 -0700 Subject: [PATCH 13/20] add more tests for web utils --- src/idom/web/module.py | 5 ++- src/idom/web/templates/preact.js | 13 +++----- src/idom/web/templates/react.js | 3 +- src/idom/web/utils.py | 33 ++++++++++--------- .../js_fixtures/export-resolution/one.js | 3 +- tests/test_web/test_utils.py | 22 ++++++++++--- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/idom/web/module.py b/src/idom/web/module.py index f0ddd3523..f5c100fd6 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -16,6 +16,7 @@ from .utils import ( resolve_module_exports_from_file, resolve_module_exports_from_url, + url_suffix, web_module_path, ) @@ -57,8 +58,10 @@ def module_from_template( resolve_exports_depth: int = 5, ) -> WebModule: cdn = cdn.rstrip("/") + template_file = ( + Path(__file__).parent / "templates" / f"{template}{url_suffix(name)}" + ) - template_file = Path(__file__).parent / "templates" / f"{template}.js" if not template_file.exists(): raise ValueError(f"No template for {template!r} exists") diff --git a/src/idom/web/templates/preact.js b/src/idom/web/templates/preact.js index bce648206..773463137 100644 --- a/src/idom/web/templates/preact.js +++ b/src/idom/web/templates/preact.js @@ -1,12 +1,9 @@ -export * from "$CDN/$PACKAGE"; - -import { h, Component, render } from "$CDN/preact"; -import htm from "$CDN/htm"; - -const html = htm.bind(h); +import { render } from "$CDN/preact"; -export { h as createElement, render as renderElement }; +export { h as createElement, render as renderElement } from "$CDN/preact"; export function unmountElement(container) { - preactRender(null, container); + render(null, container); } + +export * from "$CDN/$PACKAGE"; diff --git a/src/idom/web/templates/react.js b/src/idom/web/templates/react.js index 1b27f42ff..71c3eff1a 100644 --- a/src/idom/web/templates/react.js +++ b/src/idom/web/templates/react.js @@ -3,6 +3,7 @@ export * from "$CDN/$PACKAGE"; import * as react from "$CDN/react"; import * as reactDOM from "$CDN/react-dom"; -export const createElement = (component, props) => react.createElement(component, props); +export const createElement = (component, props) => + react.createElement(component, props); export const renderElement = reactDOM.render; export const unmountElement = reactDOM.unmountComponentAtNode; diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index cec97037d..819cc1375 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -1,6 +1,6 @@ import logging import re -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Set, Tuple from urllib.parse import urlparse @@ -12,9 +12,16 @@ logger = logging.getLogger(__name__) +def url_suffix(name: str) -> str: + head, _, tail = name.partition("@") # handle version identifier + version, _, tail = tail.partition("/") # get section after version + return PurePosixPath(tail or head).suffix or ".js" + + def web_module_path(name: str) -> Path: + name += url_suffix(name) path = IDOM_WED_MODULES_DIR.current.joinpath(*name.split("/")) - return path.with_suffix(path.suffix + ".js") + return path.with_suffix(path.suffix) def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: @@ -31,7 +38,7 @@ def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: if urlparse(ref).scheme: # is an absolute URL export_names.update(resolve_module_exports_from_url(ref, max_depth - 1)) else: - path = _resolve_relative_file_path(file, ref) + path = file.parent.joinpath(*ref.split("/")) export_names.update(resolve_module_exports_from_file(path, max_depth - 1)) return export_names @@ -102,23 +109,19 @@ def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str] return {n.strip() for n in names}, {r.strip() for r in references} -def _resolve_relative_file_path(base_path: Path, rel_url: str) -> Path: - if rel_url.startswith("./"): - return base_path.parent / rel_url[2:] - while rel_url.startswith("../"): - base_path = base_path.parent - rel_url = rel_url[3:] - return base_path / rel_url - - def _resolve_relative_url(base_url: str, rel_url: str) -> str: if not rel_url.startswith("."): return rel_url - elif rel_url.startswith("./"): - return base_url.rsplit("/")[0] + rel_url[1:] + + base_url = base_url.rsplit("/", 1)[0] + + if rel_url.startswith("./"): + return base_url + rel_url[1:] + while rel_url.startswith("../"): - base_url = base_url.rsplit("/")[0] + base_url = base_url.rsplit("/", 1)[0] rel_url = rel_url[3:] + return f"{base_url}/{rel_url}" diff --git a/tests/test_web/js_fixtures/export-resolution/one.js b/tests/test_web/js_fixtures/export-resolution/one.js index 159b50564..a0355241f 100644 --- a/tests/test_web/js_fixtures/export-resolution/one.js +++ b/tests/test_web/js_fixtures/export-resolution/one.js @@ -1,2 +1,3 @@ export {one as One}; -export * from "./two.js"; +// use ../ just to check that it works +export * from "../export-resolution/two.js"; diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 202d4111b..dd657694a 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -58,18 +58,30 @@ def test_resolve_module_exports_from_file_log_on_unknown_file_location( def test_resolve_module_exports_from_url(): responses.add( responses.GET, - "https://first.url", - body="export const First = 1; export * from 'https://second.url';", + "https://some.url/first.js", + body="export const First = 1; export * from 'https://another.url/path/second.js';", ) responses.add( responses.GET, - "https://second.url", - body="export const Second = 2;", + "https://another.url/path/second.js", + body="export const Second = 2; export * from '../third.js';", + ) + responses.add( + responses.GET, + "https://another.url/third.js", + body="export const Third = 3; export * from './fourth.js';", + ) + responses.add( + responses.GET, + "https://another.url/fourth.js", + body="export const Fourth = 4;", ) - assert resolve_module_exports_from_url("https://first.url", 2) == { + assert resolve_module_exports_from_url("https://some.url/first.js", 4) == { "First", "Second", + "Third", + "Fourth", } From 18e02aba70503c1c906431bed9fef31bc29cb52b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 15 Jun 2021 23:03:24 -0700 Subject: [PATCH 14/20] do not assume source suffix in client --- .../packages/idom-app-react/src/index.js | 2 +- src/idom/web/module.py | 21 ++++++++++++------- src/idom/web/utils.py | 10 +-------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/idom/client/packages/idom-app-react/src/index.js b/src/idom/client/packages/idom-app-react/src/index.js index 3f9f492fa..0e24439ac 100644 --- a/src/idom/client/packages/idom-app-react/src/index.js +++ b/src/idom/client/packages/idom-app-react/src/index.js @@ -26,7 +26,7 @@ function getWebSocketEndpoint() { } function loadImportSource(source, sourceType) { - return import(sourceType == "NAME" ? `/modules/${source}.js` : source); + return import(sourceType == "NAME" ? `/modules/${source}` : source); } function shouldReconnect() { diff --git a/src/idom/web/module.py b/src/idom/web/module.py index f5c100fd6..77666d699 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -10,14 +10,13 @@ from pathlib import Path from typing import Any, List, NewType, Optional, Set, Tuple, Union, overload -from idom.config import IDOM_DEBUG_MODE +from idom.config import IDOM_DEBUG_MODE, IDOM_WED_MODULES_DIR from idom.core.vdom import ImportSourceDict, VdomDictConstructor, make_vdom_constructor from .utils import ( + module_name_suffix, resolve_module_exports_from_file, resolve_module_exports_from_url, - url_suffix, - web_module_path, ) @@ -59,13 +58,13 @@ def module_from_template( ) -> WebModule: cdn = cdn.rstrip("/") template_file = ( - Path(__file__).parent / "templates" / f"{template}{url_suffix(name)}" + Path(__file__).parent / "templates" / f"{template}{module_name_suffix(name)}" ) if not template_file.exists(): raise ValueError(f"No template for {template!r} exists") - target_file = web_module_path(name) + target_file = _web_module_path(name) if not target_file.exists(): target_file.parent.mkdir(parents=True, exist_ok=True) target_file.write_text( @@ -73,7 +72,7 @@ def module_from_template( ) return WebModule( - source=name, + source=name + module_name_suffix(name), source_type=NAME_SOURCE, default_fallback=fallback, file=target_file, @@ -93,7 +92,7 @@ def module_from_file( resolve_exports_depth: int = 5, ) -> WebModule: source_file = Path(file) - target_file = web_module_path(name) + target_file = _web_module_path(name) if target_file.exists(): if target_file.resolve() != source_file.resolve(): raise ValueError(f"{name!r} already exists as {target_file.resolve()}") @@ -101,7 +100,7 @@ def module_from_file( target_file.parent.mkdir(parents=True, exist_ok=True) target_file.symlink_to(source_file) return WebModule( - source=name, + source=name + module_name_suffix(name), source_type=NAME_SOURCE, default_fallback=fallback, file=target_file, @@ -180,3 +179,9 @@ def _make_export( fallback=(fallback or web_module.default_fallback), ), ) + + +def _web_module_path(name: str) -> Path: + name += module_name_suffix(name) + path = IDOM_WED_MODULES_DIR.current.joinpath(*name.split("/")) + return path.with_suffix(path.suffix) diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 819cc1375..3826da0b7 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -6,24 +6,16 @@ import requests -from idom.config import IDOM_WED_MODULES_DIR - logger = logging.getLogger(__name__) -def url_suffix(name: str) -> str: +def module_name_suffix(name: str) -> str: head, _, tail = name.partition("@") # handle version identifier version, _, tail = tail.partition("/") # get section after version return PurePosixPath(tail or head).suffix or ".js" -def web_module_path(name: str) -> Path: - name += url_suffix(name) - path = IDOM_WED_MODULES_DIR.current.joinpath(*name.split("/")) - return path.with_suffix(path.suffix) - - def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: if max_depth == 0: logger.warning(f"Did not resolve all exports for {file} - max depth reached") From ce3ceb6ba730696597aaa48b06bd244679d46115 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Jun 2021 00:45:27 -0700 Subject: [PATCH 15/20] test web module --- docs/source/examples/super_simple_chart.js | 4 +- src/idom/web/module.py | 18 +-- .../js_fixtures/exports-two-components.js | 18 +++ tests/test_web/js_fixtures/simple-button.js | 13 +- tests/test_web/test_module.py | 120 +++++++++++++++++- 5 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 tests/test_web/js_fixtures/exports-two-components.js diff --git a/docs/source/examples/super_simple_chart.js b/docs/source/examples/super_simple_chart.js index 0e220d2d9..490cc819b 100644 --- a/docs/source/examples/super_simple_chart.js +++ b/docs/source/examples/super_simple_chart.js @@ -1,4 +1,4 @@ -import { h, Component, render } from "https://unpkg.com/preact?module"; +import { h, render } from "https://unpkg.com/preact?module"; import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); @@ -6,7 +6,7 @@ const html = htm.bind(h); export { h as createElement, render as renderElement }; export function unmountElement(container) { - preactRender(null, container); + render(null, container); } export function SuperSimpleChart(props) { diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 77666d699..0e95c4b2f 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -57,12 +57,11 @@ def module_from_template( resolve_exports_depth: int = 5, ) -> WebModule: cdn = cdn.rstrip("/") - template_file = ( - Path(__file__).parent / "templates" / f"{template}{module_name_suffix(name)}" - ) + template_file_name = f"{template}{module_name_suffix(name)}" + template_file = Path(__file__).parent / "templates" / template_file_name if not template_file.exists(): - raise ValueError(f"No template for {template!r} exists") + raise ValueError(f"No template for {template_file_name!r} exists") target_file = _web_module_path(name) if not target_file.exists(): @@ -93,9 +92,10 @@ def module_from_file( ) -> WebModule: source_file = Path(file) target_file = _web_module_path(name) - if target_file.exists(): - if target_file.resolve() != source_file.resolve(): - raise ValueError(f"{name!r} already exists as {target_file.resolve()}") + if not source_file.exists(): + raise FileNotFoundError(f"Source file does not exist: {source_file}") + elif target_file.exists() or target_file.is_symlink(): + raise FileExistsError(f"{name!r} already exists as {target_file.resolve()}") else: target_file.parent.mkdir(parents=True, exist_ok=True) target_file.symlink_to(source_file) @@ -156,7 +156,9 @@ def export( return _make_export(web_module, export_names, fallback, allow_children) else: if web_module.export_names is not None: - missing = list(set(export_names).difference(web_module.export_names)) + missing = list( + sorted(set(export_names).difference(web_module.export_names)) + ) if missing: raise ValueError(f"{web_module.source!r} does not export {missing!r}") return [ diff --git a/tests/test_web/js_fixtures/exports-two-components.js b/tests/test_web/js_fixtures/exports-two-components.js new file mode 100644 index 000000000..4266a24ca --- /dev/null +++ b/tests/test_web/js_fixtures/exports-two-components.js @@ -0,0 +1,18 @@ +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export { h as createElement, render as renderElement }; + +export function unmountElement(container) { + render(null, container); +} + +export function Header1(props) { + return h("h1", {id: props.id}, props.text); +} + +export function Header2(props) { + return h("h2", {id: props.id}, props.text); +} diff --git a/tests/test_web/js_fixtures/simple-button.js b/tests/test_web/js_fixtures/simple-button.js index 91d6ad121..ab2b13788 100644 --- a/tests/test_web/js_fixtures/simple-button.js +++ b/tests/test_web/js_fixtures/simple-button.js @@ -1,7 +1,16 @@ -import react from "./react.js"; +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export { h as createElement, render as renderElement }; + +export function unmountElement(container) { + render(null, container); +} export function SimpleButton(props) { - return react.createElement( + return h( "button", { id: props.id, diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index e4e30ad8a..9f95cab18 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,12 +1,21 @@ from pathlib import Path +import pytest +from sanic import Sanic +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait + import idom +from idom.server.sanic import PerClientStateServer +from idom.testing import ServerMountPoint +from idom.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" -def test_that_js_module_unmount_is_called(driver, driver_wait, display): +def test_that_js_module_unmount_is_called(driver, display): SomeComponent = idom.web.export( idom.web.module_from_file( "set-flag-when-unmount-is-called", @@ -37,3 +46,112 @@ def ShowCurrentComponent(): # the unmount callback for the old component was called driver.find_element_by_id("unmount-flag") + + +def test_module_from_url(driver): + app = Sanic() + + # instead of directing the URL to a CDN, we just point it to this static file + app.static( + "/simple-button.js", + str(JS_FIXTURES_DIR / "simple-button.js"), + content_type="text/javascript", + ) + + SimpleButton = idom.web.export( + idom.web.module_from_url("/simple-button.js", resolve_exports=False), + "SimpleButton", + ) + + @idom.component + def ShowSimpleButton(): + return SimpleButton({"id": "my-button"}) + + with ServerMountPoint(PerClientStateServer, app=app) as mount_point: + mount_point.mount(ShowSimpleButton) + driver.get(mount_point.url()) + driver.find_element_by_id("my-button") + + +def test_module_from_template_where_template_does_not_exist(): + with pytest.raises(ValueError, match="No template for 'does-not-exist.js'"): + idom.web.module_from_template("does-not-exist", "something.js") + + +def test_module_from_template(driver, display): + victory = idom.web.module_from_template("react", "victory@35.4.0") + VictoryBar = idom.web.export(victory, "VictoryBar") + display(VictoryBar) + wait = WebDriverWait(driver, 10) + wait.until( + expected_conditions.visibility_of_element_located( + (By.CLASS_NAME, "VictoryContainer") + ) + ) + + +def test_module_from_file(driver, driver_wait, display): + SimpleButton = idom.web.export( + idom.web.module_from_file( + "simple-button", JS_FIXTURES_DIR / "simple-button.js" + ), + "SimpleButton", + ) + + is_clicked = idom.Ref(False) + + @idom.component + def ShowSimpleButton(): + return SimpleButton( + {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} + ) + + display(ShowSimpleButton) + + button = driver.find_element_by_id("my-button") + button.click() + driver_wait.until(lambda d: is_clicked.current) + + +def test_module_from_file_source_conflict(tmp_path): + first_file = tmp_path / "first.js" + + with pytest.raises(FileNotFoundError, match="does not exist"): + idom.web.module_from_file("temp", first_file) + + first_file.touch() + + idom.web.module_from_file("temp", first_file) + + second_file = tmp_path / "second.js" + second_file.touch() + + with pytest.raises(FileExistsError, match="already exists"): + idom.web.module_from_file("temp", second_file) + + +def test_module_missing_exports(): + module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None) + + with pytest.raises(ValueError, match="does not export 'x'"): + idom.web.export(module, "x") + + with pytest.raises(ValueError, match=r"does not export \['x', 'y'\]"): + idom.web.export(module, ["x", "y"]) + + +def test_module_exports_multiple_components(driver, display): + Header1, Header2 = idom.web.export( + idom.web.module_from_file( + "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js" + ), + ["Header1", "Header2"], + ) + + display(lambda: Header1({"id": "my-h1"}, "My Header 1")) + + driver.find_element_by_id("my-h1") + + display(lambda: Header2({"id": "my-h2"}, "My Header 2")) + + driver.find_element_by_id("my-h2") From 8abf226b3a4d1d6591505220a7f511a236e18346 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Jun 2021 01:50:14 -0700 Subject: [PATCH 16/20] clear web modules dir after each test --- tests/conftest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 7569a84fa..2858e2279 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import inspect import os +import shutil from typing import Any, List import pytest @@ -11,6 +12,7 @@ from selenium.webdriver.support.ui import WebDriverWait import idom +from idom.config import IDOM_WED_MODULES_DIR from idom.testing import ServerMountPoint, create_simple_selenium_web_driver @@ -102,6 +104,15 @@ def driver_is_headless(pytestconfig: Config): return bool(pytestconfig.option.headless) +@pytest.fixture(autouse=True) +def _clear_web_modules_dir_after_test(): + for path in IDOM_WED_MODULES_DIR.current.iterdir(): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + def _mark_coros_as_async_tests(items: List[pytest.Item]) -> None: for item in items: if isinstance(item, pytest.Function): From d796e60376d5d7a4c3f05f76f1965ade6859f16a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 17 Jun 2021 15:35:48 -0700 Subject: [PATCH 17/20] fix documentation also includes: - fix issue where client did not remove fallback from DOM - add option to symlink module_from_file into web modules dir --- .github/workflows/test.yml | 4 +- .gitignore | 4 + docs/source/_exts/build_custom_js.py | 12 + docs/source/_exts/interactive_widget.py | 4 +- docs/source/_static/custom.js | 1955 +++++++++++++++++ docs/source/auto/api-reference.rst | 16 +- docs/source/auto/developer-apis.rst | 6 - docs/source/conf.py | 1 + docs/source/custom_js/README.md | 9 + docs/source/custom_js/package-lock.json | 451 ++++ docs/source/custom_js/package.json | 20 + docs/source/custom_js/rollup.config.js | 25 + .../src/index.js} | 43 +- docs/source/examples/matplotlib_plot.py | 2 +- docs/source/examples/simple_dashboard.py | 7 +- noxfile.py | 5 +- scripts/live_docs.py | 2 + src/idom/client/.gitignore | 3 - src/idom/client/package-lock.json | 6 +- .../idom-client-react/package-lock.json | 71 +- .../packages/idom-client-react/package.json | 4 +- .../idom-client-react/src/component.js | 10 +- src/idom/server/sanic.py | 8 +- src/idom/testing.py | 7 + src/idom/web/module.py | 7 +- src/idom/web/utils.py | 2 +- tests/conftest.py | 14 +- tests/test_web/test_module.py | 15 +- 28 files changed, 2601 insertions(+), 112 deletions(-) create mode 100644 docs/source/_exts/build_custom_js.py create mode 100644 docs/source/_static/custom.js create mode 100644 docs/source/custom_js/README.md create mode 100644 docs/source/custom_js/package-lock.json create mode 100644 docs/source/custom_js/package.json create mode 100644 docs/source/custom_js/rollup.config.js rename docs/source/{_static/js/load-widget-example.js => custom_js/src/index.js} (55%) delete mode 100644 src/idom/client/.gitignore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1234d6ff8..28bb58e15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: - name: Install Python Dependencies run: pip install -r requirements/test-run.txt - name: Run Tests - run: nox -s test -- --headless + run: nox -s test --verbose -- --headless test-python-versions: runs-on: ${{ matrix.os }} strategy: @@ -54,7 +54,7 @@ jobs: - name: Install Python Dependencies run: pip install -r requirements/test-run.txt - name: Run Tests - run: nox -s test -- --headless --no-cov + run: nox -s test --verbose -- --headless --no-cov test-javascript: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 07cc6eff7..2e8eb32af 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ pip-wheel-metadata # --- IDE --- .idea .vscode + +# --- JS --- + +node_modules diff --git a/docs/source/_exts/build_custom_js.py b/docs/source/_exts/build_custom_js.py new file mode 100644 index 000000000..233834d9c --- /dev/null +++ b/docs/source/_exts/build_custom_js.py @@ -0,0 +1,12 @@ +import subprocess +from pathlib import Path + +from sphinx.application import Sphinx + + +SOURCE_DIR = Path(__file__).parent.parent +CUSTOM_JS_DIR = SOURCE_DIR / "custom_js" + + +def setup(app: Sphinx) -> None: + subprocess.run(["npm", "run", "build"], cwd=CUSTOM_JS_DIR, shell=True) diff --git a/docs/source/_exts/interactive_widget.py b/docs/source/_exts/interactive_widget.py index 02a4696c4..5c37d1d48 100644 --- a/docs/source/_exts/interactive_widget.py +++ b/docs/source/_exts/interactive_widget.py @@ -26,8 +26,8 @@ def run(self):
""", diff --git a/docs/source/_static/custom.js b/docs/source/_static/custom.js new file mode 100644 index 000000000..5d1658510 --- /dev/null +++ b/docs/source/_static/custom.js @@ -0,0 +1,1955 @@ +function createCommonjsModule(fn, module) { + return module = { exports: {} }, fn(module, module.exports), module.exports; +} + +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ +/* eslint-disable no-unused-vars */ +var getOwnPropertySymbols = Object.getOwnPropertySymbols; +var hasOwnProperty$1 = Object.prototype.hasOwnProperty; +var propIsEnumerable = Object.prototype.propertyIsEnumerable; + +function toObject(val) { + if (val === null || val === undefined) { + throw new TypeError('Object.assign cannot be called with null or undefined'); + } + + return Object(val); +} + +function shouldUseNative() { + try { + if (!Object.assign) { + return false; + } + + // Detect buggy property enumeration order in older V8 versions. + + // https://bugs.chromium.org/p/v8/issues/detail?id=4118 + var test1 = new String('abc'); // eslint-disable-line no-new-wrappers + test1[5] = 'de'; + if (Object.getOwnPropertyNames(test1)[0] === '5') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test2 = {}; + for (var i = 0; i < 10; i++) { + test2['_' + String.fromCharCode(i)] = i; + } + var order2 = Object.getOwnPropertyNames(test2).map(function (n) { + return test2[n]; + }); + if (order2.join('') !== '0123456789') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test3 = {}; + 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { + test3[letter] = letter; + }); + if (Object.keys(Object.assign({}, test3)).join('') !== + 'abcdefghijklmnopqrst') { + return false; + } + + return true; + } catch (err) { + // We don't expect any of the above to throw, but better to be safe. + return false; + } +} + +var objectAssign = shouldUseNative() ? Object.assign : function (target, source) { + var from; + var to = toObject(target); + var symbols; + + for (var s = 1; s < arguments.length; s++) { + from = Object(arguments[s]); + + for (var key in from) { + if (hasOwnProperty$1.call(from, key)) { + to[key] = from[key]; + } + } + + if (getOwnPropertySymbols) { + symbols = getOwnPropertySymbols(from); + for (var i = 0; i < symbols.length; i++) { + if (propIsEnumerable.call(from, symbols[i])) { + to[symbols[i]] = from[symbols[i]]; + } + } + } + } + + return to; +}; + +var n$1="function"===typeof Symbol&&Symbol.for,p=n$1?Symbol.for("react.element"):60103,q=n$1?Symbol.for("react.portal"):60106,r=n$1?Symbol.for("react.fragment"):60107,t$1=n$1?Symbol.for("react.strict_mode"):60108,u$1=n$1?Symbol.for("react.profiler"):60114,v$1=n$1?Symbol.for("react.provider"):60109,w=n$1?Symbol.for("react.context"):60110,x=n$1?Symbol.for("react.forward_ref"):60112,y=n$1?Symbol.for("react.suspense"):60113,z=n$1?Symbol.for("react.memo"):60115,A=n$1?Symbol.for("react.lazy"): +60116,B="function"===typeof Symbol&&Symbol.iterator;function C$1(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;cQ$1.length&&Q$1.push(a);} +function T$1(a,b,c,e){var d=typeof a;if("undefined"===d||"boolean"===d)a=null;var g=!1;if(null===a)g=!0;else switch(d){case "string":case "number":g=!0;break;case "object":switch(a.$$typeof){case p:case q:g=!0;}}if(g)return c(e,a,""===b?"."+U$1(a,0):b),1;g=0;b=""===b?".":b+":";if(Array.isArray(a))for(var k=0;k=G};l=function(){};exports.unstable_forceFrameRate=function(a){0>a||125>>1,e=a[d];if(void 0!==e&&0K(n,c))void 0!==r&&0>K(r,n)?(a[d]=r,a[v]=c,d=v):(a[d]=n,a[m]=c,d=m);else if(void 0!==r&&0>K(r,c))a[d]=r,a[v]=c,d=v;else break a}}return b}return null}function K(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}var N=[],O=[],P=1,Q=null,R=3,S=!1,T=!1,U=!1; +function V(a){for(var b=L(O);null!==b;){if(null===b.callback)M(O);else if(b.startTime<=a)M(O),b.sortIndex=b.expirationTime,J(N,b);else break;b=L(O);}}function W(a){U=!1;V(a);if(!T)if(null!==L(N))T=!0,f(X);else {var b=L(O);null!==b&&g(W,b.startTime-a);}} +function X(a,b){T=!1;U&&(U=!1,h());S=!0;var c=R;try{V(b);for(Q=L(N);null!==Q&&(!(Q.expirationTime>b)||a&&!k());){var d=Q.callback;if(null!==d){Q.callback=null;R=Q.priorityLevel;var e=d(Q.expirationTime<=b);b=exports.unstable_now();"function"===typeof e?Q.callback=e:Q===L(N)&&M(N);V(b);}else M(N);Q=L(N);}if(null!==Q)var m=!0;else {var n=L(O);null!==n&&g(W,n.startTime-b);m=!1;}return m}finally{Q=null,R=c,S=!1;}} +function Y(a){switch(a){case 1:return -1;case 2:return 250;case 5:return 1073741823;case 4:return 1E4;default:return 5E3}}var Z=l;exports.unstable_IdlePriority=5;exports.unstable_ImmediatePriority=1;exports.unstable_LowPriority=4;exports.unstable_NormalPriority=3;exports.unstable_Profiling=null;exports.unstable_UserBlockingPriority=2;exports.unstable_cancelCallback=function(a){a.callback=null;};exports.unstable_continueExecution=function(){T||S||(T=!0,f(X));}; +exports.unstable_getCurrentPriorityLevel=function(){return R};exports.unstable_getFirstCallbackNode=function(){return L(N)};exports.unstable_next=function(a){switch(R){case 1:case 2:case 3:var b=3;break;default:b=R;}var c=R;R=b;try{return a()}finally{R=c;}};exports.unstable_pauseExecution=function(){};exports.unstable_requestPaint=Z;exports.unstable_runWithPriority=function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3;}var c=R;R=a;try{return b()}finally{R=c;}}; +exports.unstable_scheduleCallback=function(a,b,c){var d=exports.unstable_now();if("object"===typeof c&&null!==c){var e=c.delay;e="number"===typeof e&&0d?(a.sortIndex=e,J(O,a),null===L(N)&&a===L(O)&&(U?h():U=!0,g(W,e-d))):(a.sortIndex=c,J(N,a),T||S||(T=!0,f(X)));return a}; +exports.unstable_shouldYield=function(){var a=exports.unstable_now();V(a);var b=L(N);return b!==Q&&null!==Q&&null!==b&&null!==b.callback&&b.startTime<=a&&b.expirationTimeb}return !1}function v(a,b,c,d,e,f){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;}var C={}; +"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){C[a]=new v(a,0,!1,a,null,!1);});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];C[b]=new v(b,1,!1,a[1],null,!1);});["contentEditable","draggable","spellCheck","value"].forEach(function(a){C[a]=new v(a,2,!1,a.toLowerCase(),null,!1);}); +["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){C[a]=new v(a,2,!1,a,null,!1);});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){C[a]=new v(a,3,!1,a.toLowerCase(),null,!1);}); +["checked","multiple","muted","selected"].forEach(function(a){C[a]=new v(a,3,!0,a,null,!1);});["capture","download"].forEach(function(a){C[a]=new v(a,4,!1,a,null,!1);});["cols","rows","size","span"].forEach(function(a){C[a]=new v(a,6,!1,a,null,!1);});["rowSpan","start"].forEach(function(a){C[a]=new v(a,5,!1,a.toLowerCase(),null,!1);});var Ua=/[\-:]([a-z])/g;function Va(a){return a[1].toUpperCase()} +"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=a.replace(Ua, +Va);C[b]=new v(b,1,!1,a,null,!1);});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Ua,Va);C[b]=new v(b,1,!1,a,"http://www.w3.org/1999/xlink",!1);});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Ua,Va);C[b]=new v(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1);});["tabIndex","crossOrigin"].forEach(function(a){C[a]=new v(a,1,!1,a.toLowerCase(),null,!1);}); +C.xlinkHref=new v("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0);["src","href","action","formAction"].forEach(function(a){C[a]=new v(a,1,!1,a.toLowerCase(),null,!0);});var Wa=react.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;Wa.hasOwnProperty("ReactCurrentDispatcher")||(Wa.ReactCurrentDispatcher={current:null});Wa.hasOwnProperty("ReactCurrentBatchConfig")||(Wa.ReactCurrentBatchConfig={suspense:null}); +function Xa(a,b,c,d){var e=C.hasOwnProperty(b)?C[b]:null;var f=null!==e?0===e.type:d?!1:!(2=c.length))throw Error(u(93));c=c[0];}b=c;}null==b&&(b="");c=b;}a._wrapperState={initialValue:rb(c)};} +function Kb(a,b){var c=rb(b.value),d=rb(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d);}function Lb(a){var b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b);}var Mb={html:"http://www.w3.org/1999/xhtml",mathml:"http://www.w3.org/1998/Math/MathML",svg:"http://www.w3.org/2000/svg"}; +function Nb(a){switch(a){case "svg":return "http://www.w3.org/2000/svg";case "math":return "http://www.w3.org/1998/Math/MathML";default:return "http://www.w3.org/1999/xhtml"}}function Ob(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?Nb(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a} +var Pb,Qb=function(a){return "undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,c,d,e)});}:a}(function(a,b){if(a.namespaceURI!==Mb.svg||"innerHTML"in a)a.innerHTML=b;else {Pb=Pb||document.createElement("div");Pb.innerHTML=""+b.valueOf().toString()+"";for(b=Pb.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild);}}); +function Rb(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b;}function Sb(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}var Tb={animationend:Sb("Animation","AnimationEnd"),animationiteration:Sb("Animation","AnimationIteration"),animationstart:Sb("Animation","AnimationStart"),transitionend:Sb("Transition","TransitionEnd")},Ub={},Vb={}; +ya&&(Vb=document.createElement("div").style,"AnimationEvent"in window||(delete Tb.animationend.animation,delete Tb.animationiteration.animation,delete Tb.animationstart.animation),"TransitionEvent"in window||delete Tb.transitionend.transition);function Wb(a){if(Ub[a])return Ub[a];if(!Tb[a])return a;var b=Tb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in Vb)return Ub[a]=b[c];return a} +var Xb=Wb("animationend"),Yb=Wb("animationiteration"),Zb=Wb("animationstart"),$b=Wb("transitionend"),ac="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),bc=new ("function"===typeof WeakMap?WeakMap:Map);function cc(a){var b=bc.get(a);void 0===b&&(b=new Map,bc.set(a,b));return b} +function dc(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else {a=b;do b=a,0!==(b.effectTag&1026)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function ec(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function fc(a){if(dc(a)!==a)throw Error(u(188));} +function gc(a){var b=a.alternate;if(!b){b=dc(a);if(null===b)throw Error(u(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return fc(e),a;if(f===d)return fc(e),b;f=f.sibling;}throw Error(u(188));}if(c.return!==d.return)c=e,d=f;else {for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling;}if(!g){for(h=f.child;h;){if(h=== +c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling;}if(!g)throw Error(u(189));}}if(c.alternate!==d)throw Error(u(190));}if(3!==c.tag)throw Error(u(188));return c.stateNode.current===c?a:b}function hc(a){a=gc(a);if(!a)return null;for(var b=a;;){if(5===b.tag||6===b.tag)return b;if(b.child)b.child.return=b,b=b.child;else {if(b===a)break;for(;!b.sibling;){if(!b.return||b.return===a)return null;b=b.return;}b.sibling.return=b.return;b=b.sibling;}}return null} +function ic(a,b){if(null==b)throw Error(u(30));if(null==a)return b;if(Array.isArray(a)){if(Array.isArray(b))return a.push.apply(a,b),a;a.push(b);return a}return Array.isArray(b)?[a].concat(b):[a,b]}function jc(a,b,c){Array.isArray(a)?a.forEach(b,c):a&&b.call(c,a);}var kc=null; +function lc(a){if(a){var b=a._dispatchListeners,c=a._dispatchInstances;if(Array.isArray(b))for(var d=0;dpc.length&&pc.push(a);} +function rc(a,b,c,d){if(pc.length){var e=pc.pop();e.topLevelType=a;e.eventSystemFlags=d;e.nativeEvent=b;e.targetInst=c;return e}return {topLevelType:a,eventSystemFlags:d,nativeEvent:b,targetInst:c,ancestors:[]}} +function sc(a){var b=a.targetInst,c=b;do{if(!c){a.ancestors.push(c);break}var d=c;if(3===d.tag)d=d.stateNode.containerInfo;else {for(;d.return;)d=d.return;d=3!==d.tag?null:d.stateNode.containerInfo;}if(!d)break;b=c.tag;5!==b&&6!==b||a.ancestors.push(c);c=tc(d);}while(c);for(c=0;c=b)return {node:c,offset:b-a};a=d;}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode;}c=void 0;}c=ud(c);}} +function wd(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?wd(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function xd(){for(var a=window,b=td();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href;}catch(d){c=!1;}if(c)a=b.contentWindow;else break;b=td(a.document);}return b} +function yd(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}var zd="$",Ad="/$",Bd="$?",Cd="$!",Dd=null,Ed=null;function Fd(a,b){switch(a){case "button":case "input":case "select":case "textarea":return !!b.autoFocus}return !1} +function Gd(a,b){return "textarea"===a||"option"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}var Hd="function"===typeof setTimeout?setTimeout:void 0,Id="function"===typeof clearTimeout?clearTimeout:void 0;function Jd(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break}return a} +function Kd(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if(c===zd||c===Cd||c===Bd){if(0===b)return a;b--;}else c===Ad&&b++;}a=a.previousSibling;}return null}var Ld=Math.random().toString(36).slice(2),Md="__reactInternalInstance$"+Ld,Nd="__reactEventHandlers$"+Ld,Od="__reactContainere$"+Ld; +function tc(a){var b=a[Md];if(b)return b;for(var c=a.parentNode;c;){if(b=c[Od]||c[Md]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=Kd(a);null!==a;){if(c=a[Md])return c;a=Kd(a);}return b}a=c;c=a.parentNode;}return null}function Nc(a){a=a[Md]||a[Od];return !a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Pd(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(u(33));}function Qd(a){return a[Nd]||null} +function Rd(a){do a=a.return;while(a&&5!==a.tag);return a?a:null} +function Sd(a,b){var c=a.stateNode;if(!c)return null;var d=la(c);if(!d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1;}if(a)return null;if(c&&"function"!==typeof c)throw Error(u(231, +b,typeof c));return c}function Td(a,b,c){if(b=Sd(a,c.dispatchConfig.phasedRegistrationNames[b]))c._dispatchListeners=ic(c._dispatchListeners,b),c._dispatchInstances=ic(c._dispatchInstances,a);}function Ud(a){if(a&&a.dispatchConfig.phasedRegistrationNames){for(var b=a._targetInst,c=[];b;)c.push(b),b=Rd(b);for(b=c.length;0this.eventPool.length&&this.eventPool.push(a);}function de(a){a.eventPool=[];a.getPooled=ee;a.release=fe;}var ge=G.extend({data:null}),he=G.extend({data:null}),ie=[9,13,27,32],je=ya&&"CompositionEvent"in window,ke=null;ya&&"documentMode"in document&&(ke=document.documentMode); +var le=ya&&"TextEvent"in window&&!ke,me=ya&&(!je||ke&&8=ke),ne=String.fromCharCode(32),oe={beforeInput:{phasedRegistrationNames:{bubbled:"onBeforeInput",captured:"onBeforeInputCapture"},dependencies:["compositionend","keypress","textInput","paste"]},compositionEnd:{phasedRegistrationNames:{bubbled:"onCompositionEnd",captured:"onCompositionEndCapture"},dependencies:"blur compositionend keydown keypress keyup mousedown".split(" ")},compositionStart:{phasedRegistrationNames:{bubbled:"onCompositionStart", +captured:"onCompositionStartCapture"},dependencies:"blur compositionstart keydown keypress keyup mousedown".split(" ")},compositionUpdate:{phasedRegistrationNames:{bubbled:"onCompositionUpdate",captured:"onCompositionUpdateCapture"},dependencies:"blur compositionupdate keydown keypress keyup mousedown".split(" ")}},pe=!1; +function qe(a,b){switch(a){case "keyup":return -1!==ie.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "blur":return !0;default:return !1}}function re(a){a=a.detail;return "object"===typeof a&&"data"in a?a.data:null}var se=!1;function te(a,b){switch(a){case "compositionend":return re(b);case "keypress":if(32!==b.which)return null;pe=!0;return ne;case "textInput":return a=b.data,a===ne&&pe?null:a;default:return null}} +function ue(a,b){if(se)return "compositionend"===a||!je&&qe(a,b)?(a=ae(),$d=Zd=Yd=null,se=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1=document.documentMode,df={select:{phasedRegistrationNames:{bubbled:"onSelect",captured:"onSelectCapture"},dependencies:"blur contextmenu dragend focus keydown keyup mousedown mouseup selectionchange".split(" ")}},ef=null,ff=null,gf=null,hf=!1; +function jf(a,b){var c=b.window===b?b.document:9===b.nodeType?b:b.ownerDocument;if(hf||null==ef||ef!==td(c))return null;c=ef;"selectionStart"in c&&yd(c)?c={start:c.selectionStart,end:c.selectionEnd}:(c=(c.ownerDocument&&c.ownerDocument.defaultView||window).getSelection(),c={anchorNode:c.anchorNode,anchorOffset:c.anchorOffset,focusNode:c.focusNode,focusOffset:c.focusOffset});return gf&&bf(gf,c)?null:(gf=c,a=G.getPooled(df.select,ff,a,b),a.type="select",a.target=ef,Xd(a),a)} +var kf={eventTypes:df,extractEvents:function(a,b,c,d,e,f){e=f||(d.window===d?d.document:9===d.nodeType?d:d.ownerDocument);if(!(f=!e)){a:{e=cc(e);f=wa.onSelect;for(var g=0;gzf||(a.current=yf[zf],yf[zf]=null,zf--);} +function I(a,b){zf++;yf[zf]=a.current;a.current=b;}var Af={},J={current:Af},K={current:!1},Bf=Af;function Cf(a,b){var c=a.type.contextTypes;if(!c)return Af;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function L(a){a=a.childContextTypes;return null!==a&&void 0!==a} +function Df(){H(K);H(J);}function Ef(a,b,c){if(J.current!==Af)throw Error(u(168));I(J,b);I(K,c);}function Ff(a,b,c){var d=a.stateNode;a=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in a))throw Error(u(108,pb(b)||"Unknown",e));return objectAssign({},c,{},d)}function Gf(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||Af;Bf=J.current;I(J,a);I(K,K.current);return !0} +function Hf(a,b,c){var d=a.stateNode;if(!d)throw Error(u(169));c?(a=Ff(a,b,Bf),d.__reactInternalMemoizedMergedChildContext=a,H(K),H(J),I(J,a)):H(K);I(K,c);} +var If=scheduler.unstable_runWithPriority,Jf=scheduler.unstable_scheduleCallback,Kf=scheduler.unstable_cancelCallback,Lf=scheduler.unstable_requestPaint,Mf=scheduler.unstable_now,Nf=scheduler.unstable_getCurrentPriorityLevel,Of=scheduler.unstable_ImmediatePriority,Pf=scheduler.unstable_UserBlockingPriority,Qf=scheduler.unstable_NormalPriority,Rf=scheduler.unstable_LowPriority,Sf=scheduler.unstable_IdlePriority,Tf={},Uf=scheduler.unstable_shouldYield,Vf=void 0!==Lf?Lf:function(){},Wf=null,Xf=null,Yf=!1,Zf=Mf(),$f=1E4>Zf?Mf:function(){return Mf()-Zf}; +function ag(){switch(Nf()){case Of:return 99;case Pf:return 98;case Qf:return 97;case Rf:return 96;case Sf:return 95;default:throw Error(u(332));}}function bg(a){switch(a){case 99:return Of;case 98:return Pf;case 97:return Qf;case 96:return Rf;case 95:return Sf;default:throw Error(u(332));}}function cg(a,b){a=bg(a);return If(a,b)}function dg(a,b,c){a=bg(a);return Jf(a,b,c)}function eg(a){null===Wf?(Wf=[a],Xf=Jf(Of,fg)):Wf.push(a);return Tf}function gg(){if(null!==Xf){var a=Xf;Xf=null;Kf(a);}fg();} +function fg(){if(!Yf&&null!==Wf){Yf=!0;var a=0;try{var b=Wf;cg(99,function(){for(;a=b&&(rg=!0),a.firstContext=null);} +function sg(a,b){if(mg!==a&&!1!==b&&0!==b){if("number"!==typeof b||1073741823===b)mg=a,b=1073741823;b={context:a,observedBits:b,next:null};if(null===lg){if(null===kg)throw Error(u(308));lg=b;kg.dependencies={expirationTime:0,firstContext:b,responders:null};}else lg=lg.next=b;}return a._currentValue}var tg=!1;function ug(a){a.updateQueue={baseState:a.memoizedState,baseQueue:null,shared:{pending:null},effects:null};} +function vg(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue={baseState:a.baseState,baseQueue:a.baseQueue,shared:a.shared,effects:a.effects});}function wg(a,b){a={expirationTime:a,suspenseConfig:b,tag:0,payload:null,callback:null,next:null};return a.next=a}function xg(a,b){a=a.updateQueue;if(null!==a){a=a.shared;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b;}} +function yg(a,b){var c=a.alternate;null!==c&&vg(c,a);a=a.updateQueue;c=a.baseQueue;null===c?(a.baseQueue=b.next=b,b.next=b):(b.next=c.next,c.next=b);} +function zg(a,b,c,d){var e=a.updateQueue;tg=!1;var f=e.baseQueue,g=e.shared.pending;if(null!==g){if(null!==f){var h=f.next;f.next=g.next;g.next=h;}f=g;e.shared.pending=null;h=a.alternate;null!==h&&(h=h.updateQueue,null!==h&&(h.baseQueue=g));}if(null!==f){h=f.next;var k=e.baseState,l=0,m=null,p=null,x=null;if(null!==h){var z=h;do{g=z.expirationTime;if(gl&&(l=g);}else {null!==x&&(x=x.next={expirationTime:1073741823,suspenseConfig:z.suspenseConfig,tag:z.tag,payload:z.payload,callback:z.callback,next:null});Ag(g,z.suspenseConfig);a:{var D=a,t=z;g=b;ca=c;switch(t.tag){case 1:D=t.payload;if("function"===typeof D){k=D.call(ca,k,g);break a}k=D;break a;case 3:D.effectTag=D.effectTag&-4097|64;case 0:D=t.payload;g="function"===typeof D?D.call(ca,k,g):D;if(null===g||void 0===g)break a;k=objectAssign({},k,g);break a;case 2:tg=!0;}}null!==z.callback&& +(a.effectTag|=32,g=e.effects,null===g?e.effects=[z]:g.push(z));}z=z.next;if(null===z||z===h)if(g=e.shared.pending,null===g)break;else z=f.next=g.next,g.next=h,e.baseQueue=f=g,e.shared.pending=null;}while(1)}null===x?m=k:x.next=p;e.baseState=m;e.baseQueue=x;Bg(l);a.expirationTime=l;a.memoizedState=k;}} +function Cg(a,b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;by?(A=m,m=null):A=m.sibling;var q=x(e,m,h[y],k);if(null===q){null===m&&(m=A);break}a&& +m&&null===q.alternate&&b(e,m);g=f(q,g,y);null===t?l=q:t.sibling=q;t=q;m=A;}if(y===h.length)return c(e,m),l;if(null===m){for(;yy?(A=t,t=null):A=t.sibling;var D=x(e,t,q.value,l);if(null===D){null===t&&(t=A);break}a&&t&&null===D.alternate&&b(e,t);g=f(D,g,y);null===m?k=D:m.sibling=D;m=D;t=A;}if(q.done)return c(e,t),k;if(null===t){for(;!q.done;y++,q=h.next())q=p(e,q.value,l),null!==q&&(g=f(q,g,y),null===m?k=q:m.sibling=q,m=q);return k}for(t=d(e,t);!q.done;y++,q=h.next())q=z(t,e,y,q.value,l),null!==q&&(a&&null!== +q.alternate&&t.delete(null===q.key?y:q.key),g=f(q,g,y),null===m?k=q:m.sibling=q,m=q);a&&t.forEach(function(a){return b(e,a)});return k}return function(a,d,f,h){var k="object"===typeof f&&null!==f&&f.type===ab&&null===f.key;k&&(f=f.props.children);var l="object"===typeof f&&null!==f;if(l)switch(f.$$typeof){case Za:a:{l=f.key;for(k=d;null!==k;){if(k.key===l){switch(k.tag){case 7:if(f.type===ab){c(a,k.sibling);d=e(k,f.props.children);d.return=a;a=d;break a}break;default:if(k.elementType===f.type){c(a, +k.sibling);d=e(k,f.props);d.ref=Pg(a,k,f);d.return=a;a=d;break a}}c(a,k);break}else b(a,k);k=k.sibling;}f.type===ab?(d=Wg(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=Ug(f.type,f.key,f.props,null,a.mode,h),h.ref=Pg(a,d,f),h.return=a,a=h);}return g(a);case $a:a:{for(k=f.key;null!==d;){if(d.key===k)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else {c(a,d);break}else b(a,d);d= +d.sibling;}d=Vg(f,a.mode,h);d.return=a;a=d;}return g(a)}if("string"===typeof f||"number"===typeof f)return f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Tg(f,a.mode,h),d.return=a,a=d),g(a);if(Og(f))return ca(a,d,f,h);if(nb(f))return D(a,d,f,h);l&&Qg(a,f);if("undefined"===typeof f&&!k)switch(a.tag){case 1:case 0:throw a=a.type,Error(u(152,a.displayName||a.name||"Component"));}return c(a,d)}}var Xg=Rg(!0),Yg=Rg(!1),Zg={},$g={current:Zg},ah={current:Zg},bh={current:Zg}; +function ch(a){if(a===Zg)throw Error(u(174));return a}function dh(a,b){I(bh,b);I(ah,a);I($g,Zg);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:Ob(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=Ob(b,a);}H($g);I($g,b);}function eh(){H($g);H(ah);H(bh);}function fh(a){ch(bh.current);var b=ch($g.current);var c=Ob(b,a.type);b!==c&&(I(ah,a),I($g,c));}function gh(a){ah.current===a&&(H($g),H(ah));}var M={current:0}; +function hh(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||c.data===Bd||c.data===Cd))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.effectTag&64))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return;}b.sibling.return=b.return;b=b.sibling;}return null}function ih(a,b){return {responder:a,props:b}} +var jh=Wa.ReactCurrentDispatcher,kh=Wa.ReactCurrentBatchConfig,lh=0,N=null,O=null,P=null,mh=!1;function Q(){throw Error(u(321));}function nh(a,b){if(null===b)return !1;for(var c=0;cf))throw Error(u(301));f+=1;P=O=null;b.updateQueue=null;jh.current=rh;a=c(d,e);}while(b.expirationTime===lh)}jh.current=sh;b=null!==O&&null!==O.next;lh=0;P=O=N=null;mh=!1;if(b)throw Error(u(300));return a} +function th(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===P?N.memoizedState=P=a:P=P.next=a;return P}function uh(){if(null===O){var a=N.alternate;a=null!==a?a.memoizedState:null;}else a=O.next;var b=null===P?N.memoizedState:P.next;if(null!==b)P=b,O=a;else {if(null===a)throw Error(u(310));O=a;a={memoizedState:O.memoizedState,baseState:O.baseState,baseQueue:O.baseQueue,queue:O.queue,next:null};null===P?N.memoizedState=P=a:P=P.next=a;}return P} +function vh(a,b){return "function"===typeof b?b(a):b} +function wh(a){var b=uh(),c=b.queue;if(null===c)throw Error(u(311));c.lastRenderedReducer=a;var d=O,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g;}d.baseQueue=e=f;c.pending=null;}if(null!==e){e=e.next;d=d.baseState;var h=g=f=null,k=e;do{var l=k.expirationTime;if(lN.expirationTime&& +(N.expirationTime=l,Bg(l));}else null!==h&&(h=h.next={expirationTime:1073741823,suspenseConfig:k.suspenseConfig,action:k.action,eagerReducer:k.eagerReducer,eagerState:k.eagerState,next:null}),Ag(l,k.suspenseConfig),d=k.eagerReducer===a?k.eagerState:a(d,k.action);k=k.next;}while(null!==k&&k!==e);null===h?f=d:h.next=g;$e(d,b.memoizedState)||(rg=!0);b.memoizedState=d;b.baseState=f;b.baseQueue=h;c.lastRenderedState=d;}return [b.memoizedState,c.dispatch]} +function xh(a){var b=uh(),c=b.queue;if(null===c)throw Error(u(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);$e(f,b.memoizedState)||(rg=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f;}return [f,d]} +function yh(a){var b=th();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a=b.queue={pending:null,dispatch:null,lastRenderedReducer:vh,lastRenderedState:a};a=a.dispatch=zh.bind(null,N,a);return [b.memoizedState,a]}function Ah(a,b,c,d){a={tag:a,create:b,destroy:c,deps:d,next:null};b=N.updateQueue;null===b?(b={lastEffect:null},N.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a} +function Bh(){return uh().memoizedState}function Ch(a,b,c,d){var e=th();N.effectTag|=a;e.memoizedState=Ah(1|b,c,void 0,void 0===d?null:d);}function Dh(a,b,c,d){var e=uh();d=void 0===d?null:d;var f=void 0;if(null!==O){var g=O.memoizedState;f=g.destroy;if(null!==d&&nh(d,g.deps)){Ah(b,c,f,d);return}}N.effectTag|=a;e.memoizedState=Ah(1|b,c,f,d);}function Eh(a,b){return Ch(516,4,a,b)}function Fh(a,b){return Dh(516,4,a,b)}function Gh(a,b){return Dh(4,2,a,b)} +function Hh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null);};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null;}}function Ih(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Dh(4,2,Hh.bind(null,b,a),c)}function Jh(){}function Kh(a,b){th().memoizedState=[a,void 0===b?null:b];return a}function Lh(a,b){var c=uh();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&nh(b,d[1]))return d[0];c.memoizedState=[a,b];return a} +function Mh(a,b){var c=uh();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&nh(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Nh(a,b,c){var d=ag();cg(98>d?98:d,function(){a(!0);});cg(97\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(e,{is:d.is}):(a=g.createElement(e),"select"===e&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,e);a[Md]=b;a[Nd]=d;ni(a,b,!1,!1);b.stateNode=a;g=pd(e,d);switch(e){case "iframe":case "object":case "embed":F("load", +a);h=d;break;case "video":case "audio":for(h=0;hd.tailExpiration&&1b)&&tj.set(a,b)));}} +function xj(a,b){a.expirationTimea?c:a;return 2>=a&&b!==a?0:a} +function Z(a){if(0!==a.lastExpiredTime)a.callbackExpirationTime=1073741823,a.callbackPriority=99,a.callbackNode=eg(yj.bind(null,a));else {var b=zj(a),c=a.callbackNode;if(0===b)null!==c&&(a.callbackNode=null,a.callbackExpirationTime=0,a.callbackPriority=90);else {var d=Gg();1073741823===b?d=99:1===b||2===b?d=95:(d=10*(1073741821-b)-10*(1073741821-d),d=0>=d?99:250>=d?98:5250>=d?97:95);if(null!==c){var e=a.callbackPriority;if(a.callbackExpirationTime===b&&e>=d)return;c!==Tf&&Kf(c);}a.callbackExpirationTime= +b;a.callbackPriority=d;b=1073741823===b?eg(yj.bind(null,a)):dg(d,Bj.bind(null,a),{timeout:10*(1073741821-b)-$f()});a.callbackNode=b;}}} +function Bj(a,b){wj=0;if(b)return b=Gg(),Cj(a,b),Z(a),null;var c=zj(a);if(0!==c){b=a.callbackNode;if((W&(fj|gj))!==V)throw Error(u(327));Dj();a===T&&c===U||Ej(a,c);if(null!==X){var d=W;W|=fj;var e=Fj();do try{Gj();break}catch(h){Hj(a,h);}while(1);ng();W=d;cj.current=e;if(S===hj)throw b=kj,Ej(a,c),xi(a,c),Z(a),b;if(null===X)switch(e=a.finishedWork=a.current.alternate,a.finishedExpirationTime=c,d=S,T=null,d){case ti:case hj:throw Error(u(345));case ij:Cj(a,2=c){a.lastPingedTime=c;Ej(a,c);break}}f=zj(a);if(0!==f&&f!==c)break;if(0!==d&&d!==c){a.lastPingedTime=d;break}a.timeoutHandle=Hd(Jj.bind(null,a),e);break}Jj(a);break;case vi:xi(a,c);d=a.lastSuspendedTime;c===d&&(a.nextKnownPendingLevel=Ij(e));if(oj&&(e=a.lastPingedTime,0===e||e>=c)){a.lastPingedTime=c;Ej(a,c);break}e=zj(a);if(0!==e&&e!==c)break;if(0!==d&&d!==c){a.lastPingedTime= +d;break}1073741823!==mj?d=10*(1073741821-mj)-$f():1073741823===lj?d=0:(d=10*(1073741821-lj)-5E3,e=$f(),c=10*(1073741821-c)-e,d=e-d,0>d&&(d=0),d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*bj(d/1960))-d,c=d?d=0:(e=g.busyDelayMs|0,f=$f()-(10*(1073741821-f)-(g.timeoutMs|0||5E3)),d=f<=e?0:e+d-f);if(10 component higher in the tree to provide a loading indicator or placeholder to display."+qb(g));}S!== +jj&&(S=ij);h=Ai(h,g);p=f;do{switch(p.tag){case 3:k=h;p.effectTag|=4096;p.expirationTime=b;var B=Xi(p,k,b);yg(p,B);break a;case 1:k=h;var w=p.type,ub=p.stateNode;if(0===(p.effectTag&64)&&("function"===typeof w.getDerivedStateFromError||null!==ub&&"function"===typeof ub.componentDidCatch&&(null===aj||!aj.has(ub)))){p.effectTag|=4096;p.expirationTime=b;var vb=$i(p,k,b);yg(p,vb);break a}}p=p.return;}while(null!==p)}X=Pj(X);}catch(Xc){b=Xc;continue}break}while(1)} +function Fj(){var a=cj.current;cj.current=sh;return null===a?sh:a}function Ag(a,b){awi&&(wi=a);}function Kj(){for(;null!==X;)X=Qj(X);}function Gj(){for(;null!==X&&!Uf();)X=Qj(X);}function Qj(a){var b=Rj(a.alternate,a,U);a.memoizedProps=a.pendingProps;null===b&&(b=Pj(a));dj.current=null;return b} +function Pj(a){X=a;do{var b=X.alternate;a=X.return;if(0===(X.effectTag&2048)){b=si(b,X,U);if(1===U||1!==X.childExpirationTime){for(var c=0,d=X.child;null!==d;){var e=d.expirationTime,f=d.childExpirationTime;e>c&&(c=e);f>c&&(c=f);d=d.sibling;}X.childExpirationTime=c;}if(null!==b)return b;null!==a&&0===(a.effectTag&2048)&&(null===a.firstEffect&&(a.firstEffect=X.firstEffect),null!==X.lastEffect&&(null!==a.lastEffect&&(a.lastEffect.nextEffect=X.firstEffect),a.lastEffect=X.lastEffect),1a?b:a}function Jj(a){var b=ag();cg(99,Sj.bind(null,a,b));return null} +function Sj(a,b){do Dj();while(null!==rj);if((W&(fj|gj))!==V)throw Error(u(327));var c=a.finishedWork,d=a.finishedExpirationTime;if(null===c)return null;a.finishedWork=null;a.finishedExpirationTime=0;if(c===a.current)throw Error(u(177));a.callbackNode=null;a.callbackExpirationTime=0;a.callbackPriority=90;a.nextKnownPendingLevel=0;var e=Ij(c);a.firstPendingTime=e;d<=a.lastSuspendedTime?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=0:d<=a.firstSuspendedTime&&(a.firstSuspendedTime= +d-1);d<=a.lastPingedTime&&(a.lastPingedTime=0);d<=a.lastExpiredTime&&(a.lastExpiredTime=0);a===T&&(X=T=null,U=0);1h&&(l=h,h=g,g=l),l=vd(q,g),m=vd(q,h),l&&m&&(1!==w.rangeCount||w.anchorNode!==l.node||w.anchorOffset!==l.offset||w.focusNode!==m.node||w.focusOffset!==m.offset)&&(B=B.createRange(),B.setStart(l.node,l.offset),w.removeAllRanges(),g>h?(w.addRange(B),w.extend(m.node,m.offset)):(B.setEnd(m.node,m.offset),w.addRange(B))))));B=[];for(w=q;w=w.parentNode;)1===w.nodeType&&B.push({element:w,left:w.scrollLeft, +top:w.scrollTop});"function"===typeof q.focus&&q.focus();for(q=0;q=c)return ji(a,b,c);I(M,M.current&1);b=$h(a,b,c);return null!==b?b.sibling:null}I(M,M.current&1);break;case 19:d=b.childExpirationTime>=c;if(0!==(a.effectTag&64)){if(d)return mi(a,b,c);b.effectTag|=64;}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null);I(M,M.current);if(!d)return null}return $h(a,b,c)}rg=!1;}}else rg=!1;b.expirationTime=0;switch(b.tag){case 2:d=b.type;null!==a&&(a.alternate=null,b.alternate=null,b.effectTag|=2);a=b.pendingProps;e=Cf(b,J.current);qg(b,c);e=oh(null, +b,d,a,e,c);b.effectTag|=1;if("object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof){b.tag=1;b.memoizedState=null;b.updateQueue=null;if(L(d)){var f=!0;Gf(b);}else f=!1;b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null;ug(b);var g=d.getDerivedStateFromProps;"function"===typeof g&&Fg(b,d,g,a);e.updater=Jg;b.stateNode=e;e._reactInternalFiber=b;Ng(b,d,a,c);b=gi(null,b,d,!0,f,c);}else b.tag=0,R(null,b,e,c),b=b.child;return b;case 16:a:{e=b.elementType;null!==a&&(a.alternate= +null,b.alternate=null,b.effectTag|=2);a=b.pendingProps;ob(e);if(1!==e._status)throw e._result;e=e._result;b.type=e;f=b.tag=Xj(e);a=ig(e,a);switch(f){case 0:b=di(null,b,e,a,c);break a;case 1:b=fi(null,b,e,a,c);break a;case 11:b=Zh(null,b,e,a,c);break a;case 14:b=ai(null,b,e,ig(e.type,a),d,c);break a}throw Error(u(306,e,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ig(d,e),di(a,b,d,e,c);case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ig(d,e),fi(a,b,d,e,c); +case 3:hi(b);d=b.updateQueue;if(null===a||null===d)throw Error(u(282));d=b.pendingProps;e=b.memoizedState;e=null!==e?e.element:null;vg(a,b);zg(b,d,null,c);d=b.memoizedState.element;if(d===e)Xh(),b=$h(a,b,c);else {if(e=b.stateNode.hydrate)Ph=Jd(b.stateNode.containerInfo.firstChild),Oh=b,e=Qh=!0;if(e)for(c=Yg(b,null,d,c),b.child=c;c;)c.effectTag=c.effectTag&-3|1024,c=c.sibling;else R(a,b,d,c),Xh();b=b.child;}return b;case 5:return fh(b),null===a&&Uh(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps: +null,g=e.children,Gd(d,e)?g=null:null!==f&&Gd(d,f)&&(b.effectTag|=16),ei(a,b),b.mode&4&&1!==c&&e.hidden?(b.expirationTime=b.childExpirationTime=1,b=null):(R(a,b,g,c),b=b.child),b;case 6:return null===a&&Uh(b),null;case 13:return ji(a,b,c);case 4:return dh(b,b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Xg(b,null,d,c):R(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ig(d,e),Zh(a,b,d,e,c);case 7:return R(a,b,b.pendingProps,c),b.child;case 8:return R(a, +b,b.pendingProps.children,c),b.child;case 12:return R(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;g=b.memoizedProps;f=e.value;var h=b.type._context;I(jg,h._currentValue);h._currentValue=f;if(null!==g)if(h=g.value,f=$e(h,f)?0:("function"===typeof d._calculateChangedBits?d._calculateChangedBits(h,f):1073741823)|0,0===f){if(g.children===e.children&&!K.current){b=$h(a,b,c);break a}}else for(h=b.child,null!==h&&(h.return=b);null!==h;){var k=h.dependencies;if(null!== +k){g=h.child;for(var l=k.firstContext;null!==l;){if(l.context===d&&0!==(l.observedBits&f)){1===h.tag&&(l=wg(c,null),l.tag=2,xg(h,l));h.expirationTime=b&&a<=b}function xi(a,b){var c=a.firstSuspendedTime,d=a.lastSuspendedTime;cb||0===c)a.lastSuspendedTime=b;b<=a.lastPingedTime&&(a.lastPingedTime=0);b<=a.lastExpiredTime&&(a.lastExpiredTime=0);} +function yi(a,b){b>a.firstPendingTime&&(a.firstPendingTime=b);var c=a.firstSuspendedTime;0!==c&&(b>=c?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=0:b>=a.lastSuspendedTime&&(a.lastSuspendedTime=b+1),b>a.nextKnownPendingLevel&&(a.nextKnownPendingLevel=b));}function Cj(a,b){var c=a.lastExpiredTime;if(0===c||c>b)a.lastExpiredTime=b;} +function bk(a,b,c,d){var e=b.current,f=Gg(),g=Dg.suspense;f=Hg(f,e,g);a:if(c){c=c._reactInternalFiber;b:{if(dc(c)!==c||1!==c.tag)throw Error(u(170));var h=c;do{switch(h.tag){case 3:h=h.stateNode.context;break b;case 1:if(L(h.type)){h=h.stateNode.__reactInternalMemoizedMergedChildContext;break b}}h=h.return;}while(null!==h);throw Error(u(171));}if(1===c.tag){var k=c.type;if(L(k)){c=Ff(c,k,h);break a}}c=h;}else c=Af;null===b.context?b.context=c:b.pendingContext=c;b=wg(f,g);b.payload={element:a};d=void 0=== +d?null:d;null!==d&&(b.callback=d);xg(e,b);Ig(e,f);return f}function ck(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function dk(a,b){a=a.memoizedState;null!==a&&null!==a.dehydrated&&a.retryTime=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e="";},a=0;a"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0]);}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]} + +function serializeEvent(event) { + const data = {}; + + if (event.type in eventTransforms) { + Object.assign(data, eventTransforms[event.type](event)); + } + + const target = event.target; + if (target.tagName in targetTransforms) { + targetTransforms[target.tagName].forEach((trans) => + Object.assign(data, trans(target)) + ); + } + + return data; +} + +const targetTransformCategories = { + hasValue: (target) => ({ + value: target.value, + }), + hasCurrentTime: (target) => ({ + currentTime: target.currentTime, + }), + hasFiles: (target) => { + if (target?.type == "file") { + return { + files: Array.from(target.files).map((file) => ({ + lastModified: file.lastModified, + name: file.name, + size: file.size, + type: file.type, + })), + }; + } else { + return {}; + } + }, +}; + +const targetTagCategories = { + hasValue: ["BUTTON", "INPUT", "OPTION", "LI", "METER", "PROGRESS", "PARAM"], + hasCurrentTime: ["AUDIO", "VIDEO"], + hasFiles: ["INPUT"], +}; + +const targetTransforms = {}; + +Object.keys(targetTagCategories).forEach((category) => { + targetTagCategories[category].forEach((type) => { + const transforms = targetTransforms[type] || (targetTransforms[type] = []); + transforms.push(targetTransformCategories[category]); + }); +}); + +const eventTransformCategories = { + clipboard: (event) => ({ + clipboardData: event.clipboardData, + }), + composition: (event) => ({ + data: event.data, + }), + keyboard: (event) => ({ + altKey: event.altKey, + charCode: event.charCode, + ctrlKey: event.ctrlKey, + key: event.key, + keyCode: event.keyCode, + locale: event.locale, + location: event.location, + metaKey: event.metaKey, + repeat: event.repeat, + shiftKey: event.shiftKey, + which: event.which, + }), + mouse: (event) => ({ + altKey: event.altKey, + button: event.button, + buttons: event.buttons, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + pageX: event.pageX, + pageY: event.pageY, + screenX: event.screenX, + screenY: event.screenY, + shiftKey: event.shiftKey, + }), + pointer: (event) => ({ + pointerId: event.pointerId, + width: event.width, + height: event.height, + pressure: event.pressure, + tiltX: event.tiltX, + tiltY: event.tiltY, + pointerType: event.pointerType, + isPrimary: event.isPrimary, + }), + selection: () => { + return { selectedText: window.getSelection().toString() }; + }, + touch: (event) => ({ + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }), + ui: (event) => ({ + detail: event.detail, + }), + wheel: (event) => ({ + deltaMode: event.deltaMode, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + }), + animation: (event) => ({ + animationName: event.animationName, + pseudoElement: event.pseudoElement, + elapsedTime: event.elapsedTime, + }), + transition: (event) => ({ + propertyName: event.propertyName, + pseudoElement: event.pseudoElement, + elapsedTime: event.elapsedTime, + }), +}; + +const eventTypeCategories = { + clipboard: ["copy", "cut", "paste"], + composition: ["compositionend", "compositionstart", "compositionupdate"], + keyboard: ["keydown", "keypress", "keyup"], + mouse: [ + "click", + "contextmenu", + "doubleclick", + "drag", + "dragend", + "dragenter", + "dragexit", + "dragleave", + "dragover", + "dragstart", + "drop", + "mousedown", + "mouseenter", + "mouseleave", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + ], + pointer: [ + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "gotpointercapture", + "lostpointercapture", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + ], + selection: ["select"], + touch: ["touchcancel", "touchend", "touchmove", "touchstart"], + ui: ["scroll"], + wheel: ["wheel"], + animation: ["animationstart", "animationend", "animationiteration"], + transition: ["transitionend"], +}; + +const eventTransforms = {}; + +Object.keys(eventTypeCategories).forEach((category) => { + eventTypeCategories[category].forEach((type) => { + eventTransforms[type] = eventTransformCategories[category]; + }); +}); + +/*! + * https://github.com/Starcounter-Jack/JSON-Patch + * (c) 2017 Joachim Wester + * MIT license + */ +var __extends = (undefined && undefined.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var _hasOwnProperty = Object.prototype.hasOwnProperty; +function hasOwnProperty(obj, key) { + return _hasOwnProperty.call(obj, key); +} +function _objectKeys(obj) { + if (Array.isArray(obj)) { + var keys = new Array(obj.length); + for (var k = 0; k < keys.length; k++) { + keys[k] = "" + k; + } + return keys; + } + if (Object.keys) { + return Object.keys(obj); + } + var keys = []; + for (var i in obj) { + if (hasOwnProperty(obj, i)) { + keys.push(i); + } + } + return keys; +} +/** +* Deeply clone the object. +* https://jsperf.com/deep-copy-vs-json-stringify-json-parse/25 (recursiveDeepCopy) +* @param {any} obj value to clone +* @return {any} cloned obj +*/ +function _deepClone(obj) { + switch (typeof obj) { + case "object": + return JSON.parse(JSON.stringify(obj)); //Faster than ES5 clone - http://jsperf.com/deep-cloning-of-objects/5 + case "undefined": + return null; //this is how JSON.stringify behaves for array items + default: + return obj; //no need to clone primitives + } +} +//3x faster than cached /^\d+$/.test(str) +function isInteger(str) { + var i = 0; + var len = str.length; + var charCode; + while (i < len) { + charCode = str.charCodeAt(i); + if (charCode >= 48 && charCode <= 57) { + i++; + continue; + } + return false; + } + return true; +} +/** +* Escapes a json pointer path +* @param path The raw pointer +* @return the Escaped path +*/ +function escapePathComponent(path) { + if (path.indexOf('/') === -1 && path.indexOf('~') === -1) + return path; + return path.replace(/~/g, '~0').replace(/\//g, '~1'); +} +/** + * Unescapes a json pointer path + * @param path The escaped pointer + * @return The unescaped path + */ +function unescapePathComponent(path) { + return path.replace(/~1/g, '/').replace(/~0/g, '~'); +} +/** +* Recursively checks whether an object has any undefined values inside. +*/ +function hasUndefined(obj) { + if (obj === undefined) { + return true; + } + if (obj) { + if (Array.isArray(obj)) { + for (var i = 0, len = obj.length; i < len; i++) { + if (hasUndefined(obj[i])) { + return true; + } + } + } + else if (typeof obj === "object") { + var objKeys = _objectKeys(obj); + var objKeysLength = objKeys.length; + for (var i = 0; i < objKeysLength; i++) { + if (hasUndefined(obj[objKeys[i]])) { + return true; + } + } + } + } + return false; +} +function patchErrorMessageFormatter(message, args) { + var messageParts = [message]; + for (var key in args) { + var value = typeof args[key] === 'object' ? JSON.stringify(args[key], null, 2) : args[key]; // pretty print + if (typeof value !== 'undefined') { + messageParts.push(key + ": " + value); + } + } + return messageParts.join('\n'); +} +var PatchError = /** @class */ (function (_super) { + __extends(PatchError, _super); + function PatchError(message, name, index, operation, tree) { + var _newTarget = this.constructor; + var _this = _super.call(this, patchErrorMessageFormatter(message, { name: name, index: index, operation: operation, tree: tree })) || this; + _this.name = name; + _this.index = index; + _this.operation = operation; + _this.tree = tree; + Object.setPrototypeOf(_this, _newTarget.prototype); // restore prototype chain, see https://stackoverflow.com/a/48342359 + _this.message = patchErrorMessageFormatter(message, { name: name, index: index, operation: operation, tree: tree }); + return _this; + } + return PatchError; +}(Error)); + +var JsonPatchError = PatchError; +var deepClone = _deepClone; +/* We use a Javascript hash to store each + function. Each hash entry (property) uses + the operation identifiers specified in rfc6902. + In this way, we can map each patch operation + to its dedicated function in efficient way. + */ +/* The operations applicable to an object */ +var objOps = { + add: function (obj, key, document) { + obj[key] = this.value; + return { newDocument: document }; + }, + remove: function (obj, key, document) { + var removed = obj[key]; + delete obj[key]; + return { newDocument: document, removed: removed }; + }, + replace: function (obj, key, document) { + var removed = obj[key]; + obj[key] = this.value; + return { newDocument: document, removed: removed }; + }, + move: function (obj, key, document) { + /* in case move target overwrites an existing value, + return the removed value, this can be taxing performance-wise, + and is potentially unneeded */ + var removed = getValueByPointer(document, this.path); + if (removed) { + removed = _deepClone(removed); + } + var originalValue = applyOperation(document, { op: "remove", path: this.from }).removed; + applyOperation(document, { op: "add", path: this.path, value: originalValue }); + return { newDocument: document, removed: removed }; + }, + copy: function (obj, key, document) { + var valueToCopy = getValueByPointer(document, this.from); + // enforce copy by value so further operations don't affect source (see issue #177) + applyOperation(document, { op: "add", path: this.path, value: _deepClone(valueToCopy) }); + return { newDocument: document }; + }, + test: function (obj, key, document) { + return { newDocument: document, test: _areEquals(obj[key], this.value) }; + }, + _get: function (obj, key, document) { + this.value = obj[key]; + return { newDocument: document }; + } +}; +/* The operations applicable to an array. Many are the same as for the object */ +var arrOps = { + add: function (arr, i, document) { + if (isInteger(i)) { + arr.splice(i, 0, this.value); + } + else { // array props + arr[i] = this.value; + } + // this may be needed when using '-' in an array + return { newDocument: document, index: i }; + }, + remove: function (arr, i, document) { + var removedList = arr.splice(i, 1); + return { newDocument: document, removed: removedList[0] }; + }, + replace: function (arr, i, document) { + var removed = arr[i]; + arr[i] = this.value; + return { newDocument: document, removed: removed }; + }, + move: objOps.move, + copy: objOps.copy, + test: objOps.test, + _get: objOps._get +}; +/** + * Retrieves a value from a JSON document by a JSON pointer. + * Returns the value. + * + * @param document The document to get the value from + * @param pointer an escaped JSON pointer + * @return The retrieved value + */ +function getValueByPointer(document, pointer) { + if (pointer == '') { + return document; + } + var getOriginalDestination = { op: "_get", path: pointer }; + applyOperation(document, getOriginalDestination); + return getOriginalDestination.value; +} +/** + * Apply a single JSON Patch Operation on a JSON document. + * Returns the {newDocument, result} of the operation. + * It modifies the `document` and `operation` objects - it gets the values by reference. + * If you would like to avoid touching your values, clone them: + * `jsonpatch.applyOperation(document, jsonpatch._deepClone(operation))`. + * + * @param document The document to patch + * @param operation The operation to apply + * @param validateOperation `false` is without validation, `true` to use default jsonpatch's validation, or you can pass a `validateOperation` callback to be used for validation. + * @param mutateDocument Whether to mutate the original document or clone it before applying + * @param banPrototypeModifications Whether to ban modifications to `__proto__`, defaults to `true`. + * @return `{newDocument, result}` after the operation + */ +function applyOperation(document, operation, validateOperation, mutateDocument, banPrototypeModifications, index) { + if (validateOperation === void 0) { validateOperation = false; } + if (mutateDocument === void 0) { mutateDocument = true; } + if (banPrototypeModifications === void 0) { banPrototypeModifications = true; } + if (index === void 0) { index = 0; } + if (validateOperation) { + if (typeof validateOperation == 'function') { + validateOperation(operation, 0, document, operation.path); + } + else { + validator(operation, 0); + } + } + /* ROOT OPERATIONS */ + if (operation.path === "") { + var returnValue = { newDocument: document }; + if (operation.op === 'add') { + returnValue.newDocument = operation.value; + return returnValue; + } + else if (operation.op === 'replace') { + returnValue.newDocument = operation.value; + returnValue.removed = document; //document we removed + return returnValue; + } + else if (operation.op === 'move' || operation.op === 'copy') { // it's a move or copy to root + returnValue.newDocument = getValueByPointer(document, operation.from); // get the value by json-pointer in `from` field + if (operation.op === 'move') { // report removed item + returnValue.removed = document; + } + return returnValue; + } + else if (operation.op === 'test') { + returnValue.test = _areEquals(document, operation.value); + if (returnValue.test === false) { + throw new JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); + } + returnValue.newDocument = document; + return returnValue; + } + else if (operation.op === 'remove') { // a remove on root + returnValue.removed = document; + returnValue.newDocument = null; + return returnValue; + } + else if (operation.op === '_get') { + operation.value = document; + return returnValue; + } + else { /* bad operation */ + if (validateOperation) { + throw new JsonPatchError('Operation `op` property is not one of operations defined in RFC-6902', 'OPERATION_OP_INVALID', index, operation, document); + } + else { + return returnValue; + } + } + } /* END ROOT OPERATIONS */ + else { + if (!mutateDocument) { + document = _deepClone(document); + } + var path = operation.path || ""; + var keys = path.split('/'); + var obj = document; + var t = 1; //skip empty element - http://jsperf.com/to-shift-or-not-to-shift + var len = keys.length; + var existingPathFragment = undefined; + var key = void 0; + var validateFunction = void 0; + if (typeof validateOperation == 'function') { + validateFunction = validateOperation; + } + else { + validateFunction = validator; + } + while (true) { + key = keys[t]; + if (banPrototypeModifications && key == '__proto__') { + throw new TypeError('JSON-Patch: modifying `__proto__` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README'); + } + if (validateOperation) { + if (existingPathFragment === undefined) { + if (obj[key] === undefined) { + existingPathFragment = keys.slice(0, t).join('/'); + } + else if (t == len - 1) { + existingPathFragment = operation.path; + } + if (existingPathFragment !== undefined) { + validateFunction(operation, 0, document, existingPathFragment); + } + } + } + t++; + if (Array.isArray(obj)) { + if (key === '-') { + key = obj.length; + } + else { + if (validateOperation && !isInteger(key)) { + throw new JsonPatchError("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index", "OPERATION_PATH_ILLEGAL_ARRAY_INDEX", index, operation, document); + } // only parse key when it's an integer for `arr.prop` to work + else if (isInteger(key)) { + key = ~~key; + } + } + if (t >= len) { + if (validateOperation && operation.op === "add" && key > obj.length) { + throw new JsonPatchError("The specified index MUST NOT be greater than the number of elements in the array", "OPERATION_VALUE_OUT_OF_BOUNDS", index, operation, document); + } + var returnValue = arrOps[operation.op].call(operation, obj, key, document); // Apply patch + if (returnValue.test === false) { + throw new JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); + } + return returnValue; + } + } + else { + if (key && key.indexOf('~') != -1) { + key = unescapePathComponent(key); + } + if (t >= len) { + var returnValue = objOps[operation.op].call(operation, obj, key, document); // Apply patch + if (returnValue.test === false) { + throw new JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); + } + return returnValue; + } + } + obj = obj[key]; + } + } +} +/** + * Apply a full JSON Patch array on a JSON document. + * Returns the {newDocument, result} of the patch. + * It modifies the `document` object and `patch` - it gets the values by reference. + * If you would like to avoid touching your values, clone them: + * `jsonpatch.applyPatch(document, jsonpatch._deepClone(patch))`. + * + * @param document The document to patch + * @param patch The patch to apply + * @param validateOperation `false` is without validation, `true` to use default jsonpatch's validation, or you can pass a `validateOperation` callback to be used for validation. + * @param mutateDocument Whether to mutate the original document or clone it before applying + * @param banPrototypeModifications Whether to ban modifications to `__proto__`, defaults to `true`. + * @return An array of `{newDocument, result}` after the patch + */ +function applyPatch(document, patch, validateOperation, mutateDocument, banPrototypeModifications) { + if (mutateDocument === void 0) { mutateDocument = true; } + if (banPrototypeModifications === void 0) { banPrototypeModifications = true; } + if (validateOperation) { + if (!Array.isArray(patch)) { + throw new JsonPatchError('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY'); + } + } + if (!mutateDocument) { + document = _deepClone(document); + } + var results = new Array(patch.length); + for (var i = 0, length_1 = patch.length; i < length_1; i++) { + // we don't need to pass mutateDocument argument because if it was true, we already deep cloned the object, we'll just pass `true` + results[i] = applyOperation(document, patch[i], validateOperation, true, banPrototypeModifications, i); + document = results[i].newDocument; // in case root was replaced + } + results.newDocument = document; + return results; +} +/** + * Apply a single JSON Patch Operation on a JSON document. + * Returns the updated document. + * Suitable as a reducer. + * + * @param document The document to patch + * @param operation The operation to apply + * @return The updated document + */ +function applyReducer(document, operation, index) { + var operationResult = applyOperation(document, operation); + if (operationResult.test === false) { // failed test + throw new JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); + } + return operationResult.newDocument; +} +/** + * Validates a single operation. Called from `jsonpatch.validate`. Throws `JsonPatchError` in case of an error. + * @param {object} operation - operation object (patch) + * @param {number} index - index of operation in the sequence + * @param {object} [document] - object where the operation is supposed to be applied + * @param {string} [existingPathFragment] - comes along with `document` + */ +function validator(operation, index, document, existingPathFragment) { + if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) { + throw new JsonPatchError('Operation is not an object', 'OPERATION_NOT_AN_OBJECT', index, operation, document); + } + else if (!objOps[operation.op]) { + throw new JsonPatchError('Operation `op` property is not one of operations defined in RFC-6902', 'OPERATION_OP_INVALID', index, operation, document); + } + else if (typeof operation.path !== 'string') { + throw new JsonPatchError('Operation `path` property is not a string', 'OPERATION_PATH_INVALID', index, operation, document); + } + else if (operation.path.indexOf('/') !== 0 && operation.path.length > 0) { + // paths that aren't empty string should start with "/" + throw new JsonPatchError('Operation `path` property must start with "/"', 'OPERATION_PATH_INVALID', index, operation, document); + } + else if ((operation.op === 'move' || operation.op === 'copy') && typeof operation.from !== 'string') { + throw new JsonPatchError('Operation `from` property is not present (applicable in `move` and `copy` operations)', 'OPERATION_FROM_REQUIRED', index, operation, document); + } + else if ((operation.op === 'add' || operation.op === 'replace' || operation.op === 'test') && operation.value === undefined) { + throw new JsonPatchError('Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)', 'OPERATION_VALUE_REQUIRED', index, operation, document); + } + else if ((operation.op === 'add' || operation.op === 'replace' || operation.op === 'test') && hasUndefined(operation.value)) { + throw new JsonPatchError('Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)', 'OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED', index, operation, document); + } + else if (document) { + if (operation.op == "add") { + var pathLen = operation.path.split("/").length; + var existingPathLen = existingPathFragment.split("/").length; + if (pathLen !== existingPathLen + 1 && pathLen !== existingPathLen) { + throw new JsonPatchError('Cannot perform an `add` operation at the desired path', 'OPERATION_PATH_CANNOT_ADD', index, operation, document); + } + } + else if (operation.op === 'replace' || operation.op === 'remove' || operation.op === '_get') { + if (operation.path !== existingPathFragment) { + throw new JsonPatchError('Cannot perform the operation at a path that does not exist', 'OPERATION_PATH_UNRESOLVABLE', index, operation, document); + } + } + else if (operation.op === 'move' || operation.op === 'copy') { + var existingValue = { op: "_get", path: operation.from, value: undefined }; + var error = validate([existingValue], document); + if (error && error.name === 'OPERATION_PATH_UNRESOLVABLE') { + throw new JsonPatchError('Cannot perform the operation from a path that does not exist', 'OPERATION_FROM_UNRESOLVABLE', index, operation, document); + } + } + } +} +/** + * Validates a sequence of operations. If `document` parameter is provided, the sequence is additionally validated against the object document. + * If error is encountered, returns a JsonPatchError object + * @param sequence + * @param document + * @returns {JsonPatchError|undefined} + */ +function validate(sequence, document, externalValidator) { + try { + if (!Array.isArray(sequence)) { + throw new JsonPatchError('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY'); + } + if (document) { + //clone document and sequence so that we can safely try applying operations + applyPatch(_deepClone(document), _deepClone(sequence), externalValidator || true); + } + else { + externalValidator = externalValidator || validator; + for (var i = 0; i < sequence.length; i++) { + externalValidator(sequence[i], i, document, undefined); + } + } + } + catch (e) { + if (e instanceof JsonPatchError) { + return e; + } + else { + throw e; + } + } +} +// based on https://github.com/epoberezkin/fast-deep-equal +// MIT License +// Copyright (c) 2017 Evgeny Poberezkin +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +function _areEquals(a, b) { + if (a === b) + return true; + if (a && b && typeof a == 'object' && typeof b == 'object') { + var arrA = Array.isArray(a), arrB = Array.isArray(b), i, length, key; + if (arrA && arrB) { + length = a.length; + if (length != b.length) + return false; + for (i = length; i-- !== 0;) + if (!_areEquals(a[i], b[i])) + return false; + return true; + } + if (arrA != arrB) + return false; + var keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) + return false; + for (i = length; i-- !== 0;) + if (!b.hasOwnProperty(keys[i])) + return false; + for (i = length; i-- !== 0;) { + key = keys[i]; + if (!_areEquals(a[key], b[key])) + return false; + } + return true; + } + return a !== a && b !== b; +} + +var core = /*#__PURE__*/Object.freeze({ + __proto__: null, + JsonPatchError: JsonPatchError, + deepClone: deepClone, + getValueByPointer: getValueByPointer, + applyOperation: applyOperation, + applyPatch: applyPatch, + applyReducer: applyReducer, + validator: validator, + validate: validate, + _areEquals: _areEquals +}); + +/*! + * https://github.com/Starcounter-Jack/JSON-Patch + * (c) 2017 Joachim Wester + * MIT license + */ +var beforeDict = new WeakMap(); +var Mirror = /** @class */ (function () { + function Mirror(obj) { + this.observers = new Map(); + this.obj = obj; + } + return Mirror; +}()); +var ObserverInfo = /** @class */ (function () { + function ObserverInfo(callback, observer) { + this.callback = callback; + this.observer = observer; + } + return ObserverInfo; +}()); +function getMirror(obj) { + return beforeDict.get(obj); +} +function getObserverFromMirror(mirror, callback) { + return mirror.observers.get(callback); +} +function removeObserverFromMirror(mirror, observer) { + mirror.observers.delete(observer.callback); +} +/** + * Detach an observer from an object + */ +function unobserve(root, observer) { + observer.unobserve(); +} +/** + * Observes changes made to an object, which can then be retrieved using generate + */ +function observe(obj, callback) { + var patches = []; + var observer; + var mirror = getMirror(obj); + if (!mirror) { + mirror = new Mirror(obj); + beforeDict.set(obj, mirror); + } + else { + var observerInfo = getObserverFromMirror(mirror, callback); + observer = observerInfo && observerInfo.observer; + } + if (observer) { + return observer; + } + observer = {}; + mirror.value = _deepClone(obj); + if (callback) { + observer.callback = callback; + observer.next = null; + var dirtyCheck = function () { + generate(observer); + }; + var fastCheck = function () { + clearTimeout(observer.next); + observer.next = setTimeout(dirtyCheck); + }; + if (typeof window !== 'undefined') { //not Node + window.addEventListener('mouseup', fastCheck); + window.addEventListener('keyup', fastCheck); + window.addEventListener('mousedown', fastCheck); + window.addEventListener('keydown', fastCheck); + window.addEventListener('change', fastCheck); + } + } + observer.patches = patches; + observer.object = obj; + observer.unobserve = function () { + generate(observer); + clearTimeout(observer.next); + removeObserverFromMirror(mirror, observer); + if (typeof window !== 'undefined') { + window.removeEventListener('mouseup', fastCheck); + window.removeEventListener('keyup', fastCheck); + window.removeEventListener('mousedown', fastCheck); + window.removeEventListener('keydown', fastCheck); + window.removeEventListener('change', fastCheck); + } + }; + mirror.observers.set(callback, new ObserverInfo(callback, observer)); + return observer; +} +/** + * Generate an array of patches from an observer + */ +function generate(observer, invertible) { + if (invertible === void 0) { invertible = false; } + var mirror = beforeDict.get(observer.object); + _generate(mirror.value, observer.object, observer.patches, "", invertible); + if (observer.patches.length) { + applyPatch(mirror.value, observer.patches); + } + var temp = observer.patches; + if (temp.length > 0) { + observer.patches = []; + if (observer.callback) { + observer.callback(temp); + } + } + return temp; +} +// Dirty check if obj is different from mirror, generate patches and update mirror +function _generate(mirror, obj, patches, path, invertible) { + if (obj === mirror) { + return; + } + if (typeof obj.toJSON === "function") { + obj = obj.toJSON(); + } + var newKeys = _objectKeys(obj); + var oldKeys = _objectKeys(mirror); + var deleted = false; + //if ever "move" operation is implemented here, make sure this test runs OK: "should not generate the same patch twice (move)" + for (var t = oldKeys.length - 1; t >= 0; t--) { + var key = oldKeys[t]; + var oldVal = mirror[key]; + if (hasOwnProperty(obj, key) && !(obj[key] === undefined && oldVal !== undefined && Array.isArray(obj) === false)) { + var newVal = obj[key]; + if (typeof oldVal == "object" && oldVal != null && typeof newVal == "object" && newVal != null) { + _generate(oldVal, newVal, patches, path + "/" + escapePathComponent(key), invertible); + } + else { + if (oldVal !== newVal) { + if (invertible) { + patches.push({ op: "test", path: path + "/" + escapePathComponent(key), value: _deepClone(oldVal) }); + } + patches.push({ op: "replace", path: path + "/" + escapePathComponent(key), value: _deepClone(newVal) }); + } + } + } + else if (Array.isArray(mirror) === Array.isArray(obj)) { + if (invertible) { + patches.push({ op: "test", path: path + "/" + escapePathComponent(key), value: _deepClone(oldVal) }); + } + patches.push({ op: "remove", path: path + "/" + escapePathComponent(key) }); + deleted = true; // property has been deleted + } + else { + if (invertible) { + patches.push({ op: "test", path: path, value: mirror }); + } + patches.push({ op: "replace", path: path, value: obj }); + } + } + if (!deleted && newKeys.length == oldKeys.length) { + return; + } + for (var t = 0; t < newKeys.length; t++) { + var key = newKeys[t]; + if (!hasOwnProperty(mirror, key) && obj[key] !== undefined) { + patches.push({ op: "add", path: path + "/" + escapePathComponent(key), value: _deepClone(obj[key]) }); + } + } +} +/** + * Create an array of patches from the differences in two objects + */ +function compare(tree1, tree2, invertible) { + if (invertible === void 0) { invertible = false; } + var patches = []; + _generate(tree1, tree2, patches, '', invertible); + return patches; +} + +var duplex = /*#__PURE__*/Object.freeze({ + __proto__: null, + unobserve: unobserve, + observe: observe, + generate: generate, + compare: compare +}); + +var jsonpatch = Object.assign({}, core, duplex, { + JsonPatchError: PatchError, + deepClone: _deepClone, + escapePathComponent, + unescapePathComponent +}); + +function applyPatchInplace(doc, pathPrefix, patch) { + if (pathPrefix) { + patch = patch.map((op) => + Object.assign({}, op, { path: pathPrefix + op.path }) + ); + } + jsonpatch.applyPatch(doc, patch, false, true); +} + +const html = htm.bind(react.createElement); +const LayoutConfigContext = react.createContext({ + sendEvent: undefined, + loadImportSource: undefined, +}); + +function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { + const [model, patchModel] = useInplaceJsonPatch({}); + + react.useEffect(() => saveUpdateHook(patchModel), [patchModel]); + + if (model.tagName) { + return html` + <${LayoutConfigContext.Provider} value=${{ sendEvent, loadImportSource }}> + <${Element} model=${model} /> + + `; + } else { + return html`
`; + } +} + +function Element({ model, key }) { + if (model.importSource) { + return html`<${ImportedElement} model=${model} />`; + } else { + return html`<${StandardElement} model=${model} />`; + } +} + +function StandardElement({ model }) { + const config = react.useContext(LayoutConfigContext); + const children = elementChildren(model.children); + const attributes = elementAttributes(model, config.sendEvent); + if (model.children && model.children.length) { + return html`<${model.tagName} ...${attributes}>${children}`; + } else { + return html`<${model.tagName} ...${attributes} />`; + } +} + +function ImportedElement({ model }) { + const config = react.useContext(LayoutConfigContext); + config.sendEvent; + const mountPoint = react.useRef(null); + const fallback = model.importSource.fallback; + const importSource = useConst(() => + loadFromImportSource(config, model.importSource) + ); + + react.useEffect(() => { + if (fallback) { + importSource.then(() => { + reactDom.unmountComponentAtNode(mountPoint.current); + if ( mountPoint.current.children ) { + mountPoint.current.removeChild(mountPoint.current.children[0]); + } + }); + } + }, []); + + // this effect must run every time in case the model has changed + react.useEffect(() => { + importSource.then(({ createElement, renderElement }) => { + renderElement( + createElement( + model.tagName, + elementAttributes(model, config.sendEvent), + model.children + ), + mountPoint.current + ); + }); + }); + + react.useEffect( + () => () => + importSource.then(({ unmountElement }) => + unmountElement(mountPoint.current) + ), + [] + ); + + if (!fallback) { + return html`
`; + } else if (typeof fallback == "string") { + // need the second div there so we can removeChild above + return html`
${fallback}
`; + } else { + return html`
+ <${StandardElement} model=${fallback} /> +
`; + } +} + +function elementChildren(modelChildren) { + if (!modelChildren) { + return []; + } else { + return modelChildren.map((child) => { + switch (typeof child) { + case "object": + return html`<${Element} key=${child.key} model=${child} />`; + case "string": + return child; + } + }); + } +} + +function elementAttributes(model, sendEvent) { + const attributes = Object.assign({}, model.attributes); + + if (model.eventHandlers) { + for (const [eventName, eventSpec] of Object.entries(model.eventHandlers)) { + attributes[eventName] = eventHandler(sendEvent, eventSpec); + } + } + + return attributes; +} + +function eventHandler(sendEvent, eventSpec) { + return function () { + const data = Array.from(arguments).map((value) => { + if (typeof value === "object" && value.nativeEvent) { + if (eventSpec["preventDefault"]) { + value.preventDefault(); + } + if (eventSpec["stopPropagation"]) { + value.stopPropagation(); + } + return serializeEvent(value); + } else { + return value; + } + }); + new Promise((resolve, reject) => { + const msg = { + data: data, + target: eventSpec["target"], + }; + sendEvent(msg); + resolve(msg); + }); + }; +} + +function loadFromImportSource(config, importSource) { + return config + .loadImportSource(importSource.source, importSource.sourceType) + .then((module) => { + if ( + typeof module.createElement == "function" && + typeof module.renderElement == "function" && + typeof module.unmountElement == "function" + ) { + return { + createElement: (type, props) => + module.createElement(module[type], props), + renderElement: module.renderElement, + unmountElement: module.unmountElement, + }; + } else { + return { + createElement: (type, props, children) => + react.createElement( + module[type], + props, + ...elementChildren(children) + ), + renderElement: reactDom.render, + unmountElement: reactDom.unmountComponentAtNode, + }; + } + }); +} + +function useInplaceJsonPatch(doc) { + const ref = react.useRef(doc); + const forceUpdate = useForceUpdate(); + + const applyPatch = react.useCallback( + (path, patch) => { + applyPatchInplace(ref.current, path, patch); + forceUpdate(); + }, + [ref, forceUpdate] + ); + + return [ref.current, applyPatch]; +} + +function useForceUpdate() { + const [, updateState] = react.useState(); + return react.useCallback(() => updateState({}), []); +} + +function useConst(func) { + const ref = react.useRef(); + + if (!ref.current) { + ref.current = func(); + } + + return ref.current; +} + +function mountLayout(mountElement, layoutProps) { + reactDom.render(react.createElement(Layout, layoutProps), mountElement); +} + +function mountLayoutWithWebSocket( + element, + endpoint, + loadImportSource, + maxReconnectTimeout +) { + mountLayoutWithReconnectingWebSocket( + element, + endpoint, + loadImportSource, + maxReconnectTimeout + ); +} + +function mountLayoutWithReconnectingWebSocket( + element, + endpoint, + loadImportSource, + maxReconnectTimeout, + mountState = { + everMounted: false, + reconnectAttempts: 0, + reconnectTimeoutRange: 0, + } +) { + const socket = new WebSocket(endpoint); + + const updateHookPromise = new LazyPromise(); + + socket.onopen = (event) => { + console.log(`Connected.`); + + if (mountState.everMounted) { + reactDom.unmountComponentAtNode(element); + } + _resetOpenMountState(mountState); + + mountLayout(element, { + loadImportSource, + saveUpdateHook: updateHookPromise.resolve, + sendEvent: (event) => socket.send(JSON.stringify(event)), + }); + }; + + socket.onmessage = (event) => { + const [pathPrefix, patch] = JSON.parse(event.data); + updateHookPromise.promise.then((update) => update(pathPrefix, patch)); + }; + + socket.onclose = (event) => { + if (!maxReconnectTimeout) { + console.log(`Connection lost.`); + return; + } + + const reconnectTimeout = _nextReconnectTimeout( + maxReconnectTimeout, + mountState + ); + + console.log(`Connection lost, reconnecting in ${reconnectTimeout} seconds`); + + setTimeout(function () { + mountState.reconnectAttempts++; + mountLayoutWithReconnectingWebSocket( + element, + endpoint, + loadImportSource, + maxReconnectTimeout, + mountState + ); + }, reconnectTimeout * 1000); + }; +} + +function _resetOpenMountState(mountState) { + mountState.everMounted = true; + mountState.reconnectAttempts = 0; + mountState.reconnectTimeoutRange = 0; +} + +function _nextReconnectTimeout(maxReconnectTimeout, mountState) { + const timeout = + Math.floor(Math.random() * mountState.reconnectTimeoutRange) || 1; + mountState.reconnectTimeoutRange = + (mountState.reconnectTimeoutRange + 5) % maxReconnectTimeout; + if (mountState.reconnectAttempts == 4) { + window.alert( + "Server connection was lost. Attempts to reconnect are being made in the background." + ); + } + return timeout; +} + +function LazyPromise() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); +} + +const LOC = window.location; +const HTTP_PROTO = LOC.protocol; +const WS_PROTO = HTTP_PROTO === "https:" ? "wss:" : "ws:"; +const IDOM_MODULES_PATH = "/_modules"; + +function mountWidgetExample(mountID, viewID, idomServerHost) { + const idom_url = "//" + (idomServerHost || LOC.host); + const http_idom_url = HTTP_PROTO + idom_url; + const ws_idom_url = WS_PROTO + idom_url; + + const mountEl = document.getElementById(mountID); + const enableWidgetButton = document.createElement("button"); + enableWidgetButton.appendChild(document.createTextNode("Enable Widget")); + enableWidgetButton.setAttribute("class", "enable-widget-button"); + + enableWidgetButton.addEventListener("click", () => + fadeOutElementThenCallback(enableWidgetButton, () => { + { + mountEl.removeChild(enableWidgetButton); + mountEl.setAttribute("class", "interactive widget-container"); + mountLayoutWithWebSocket( + mountEl, + ws_idom_url + `/_idom/stream?view_id=${viewID}`, + (source, sourceType) => + loadImportSource(http_idom_url, source, sourceType) + ); + } + }) + ); + + function fadeOutElementThenCallback(element, callback) { + { + var op = 1; // initial opacity + var timer = setInterval(function () { + { + if (op < 0.001) { + { + clearInterval(timer); + element.style.display = "none"; + callback(); + } + } + element.style.opacity = op; + element.style.filter = "alpha(opacity=" + op * 100 + ")"; + op -= op * 0.5; + } + }, 50); + } + } + + mountEl.appendChild(enableWidgetButton); +} + +function loadImportSource(baseUrl, source, sourceType) { + if (sourceType == "NAME") { + return import(baseUrl + IDOM_MODULES_PATH + "/" + source); + } else { + return import(source); + } +} + +export { mountWidgetExample }; diff --git a/docs/source/auto/api-reference.rst b/docs/source/auto/api-reference.rst index 76645f7b7..e488298ac 100644 --- a/docs/source/auto/api-reference.rst +++ b/docs/source/auto/api-reference.rst @@ -1,12 +1,6 @@ API Reference ============= -.. automodule:: idom.client.manage - :members: - -.. automodule:: idom.client.module - :members: - .. automodule:: idom.config :members: @@ -28,7 +22,7 @@ API Reference .. automodule:: idom.core.vdom :members: -.. automodule:: idom.dialect +.. automodule:: idom.html :members: .. automodule:: idom.log @@ -55,7 +49,7 @@ API Reference .. automodule:: idom.utils :members: -.. automodule:: idom.html +.. automodule:: idom.web.module :members: .. automodule:: idom.widgets @@ -64,9 +58,6 @@ API Reference Misc Modules ------------ -.. automodule:: idom.cli - :members: - .. automodule:: idom.core.utils :members: @@ -75,3 +66,6 @@ Misc Modules .. automodule:: idom.server.utils :members: + +.. automodule:: idom.web.utils + :members: diff --git a/docs/source/auto/developer-apis.rst b/docs/source/auto/developer-apis.rst index a1fca80f5..dfe0e27a9 100644 --- a/docs/source/auto/developer-apis.rst +++ b/docs/source/auto/developer-apis.rst @@ -3,9 +3,3 @@ Developer APIs .. automodule:: idom._option :members: - -Misc Dev Modules ----------------- - -.. automodule:: idom.client._private - :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index bdf9857b1..ce3d27afa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,6 +64,7 @@ "interactive_widget", "patched_html_translator", "widget_example", + "build_custom_js", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/custom_js/README.md b/docs/source/custom_js/README.md new file mode 100644 index 000000000..8c77d450b --- /dev/null +++ b/docs/source/custom_js/README.md @@ -0,0 +1,9 @@ +# Custom Javascript for IDOM's Docs + +Build the javascript with + +``` +npm run build +``` + +This will drop a javascript bundle into `../_static/custom.js` diff --git a/docs/source/custom_js/package-lock.json b/docs/source/custom_js/package-lock.json new file mode 100644 index 000000000..4c648f3e9 --- /dev/null +++ b/docs/source/custom_js/package-lock.json @@ -0,0 +1,451 @@ +{ + "name": "idom-docs-example-loader", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "idom-docs-example-loader", + "version": "1.0.0", + "dependencies": { + "idom-client-react": "file:../../../src/idom/client/packages/idom-client-react" + }, + "devDependencies": { + "prettier": "^2.2.1", + "rollup": "^2.35.1", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0" + } + }, + "../../../src/idom/client/packages/idom-client-react": { + "version": "0.8.2", + "license": "MIT", + "dependencies": { + "fast-json-patch": "^3.0.0-1", + "htm": "^3.0.3" + }, + "devDependencies": { + "jsdom": "16.3.0", + "prettier": "^2.2.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "uvu": "^0.5.1" + }, + "peerDependencies": { + "react": "^16.13.1", + "react-dom": "^16.13.1" + } + }, + "../../src/idom/client/packages/idom-client-react": { + "extraneous": true + }, + "../src/idom/client/package/idom-client-react": { + "extraneous": true + }, + "../src/idom/client/packages/idom-client-react": { + "extraneous": true + }, + "node_modules/@types/estree": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", + "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", + "dev": true + }, + "node_modules/@types/node": { + "version": "15.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", + "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/idom-client-react": { + "resolved": "../../../src/idom/client/packages/idom-client-react", + "link": true + }, + "node_modules/is-core-module": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/prettier": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "dependencies": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.52.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", + "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-commonjs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", + "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-commonjs.", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.1" + }, + "peerDependencies": { + "rollup": ">=1.12.0" + } + }, + "node_modules/rollup-plugin-node-resolve": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", + "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-node-resolve.", + "dev": true, + "dependencies": { + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1", + "rollup-pluginutils": "^2.8.1" + }, + "peerDependencies": { + "rollup": ">=1.11.0" + } + }, + "node_modules/rollup-plugin-replace": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", + "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", + "deprecated": "This module has moved and is now available at @rollup/plugin-replace. Please update your dependencies. This version is no longer maintained.", + "dev": true, + "dependencies": { + "magic-string": "^0.25.2", + "rollup-pluginutils": "^2.6.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + } + }, + "dependencies": { + "@types/estree": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", + "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", + "dev": true + }, + "@types/node": { + "version": "15.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", + "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", + "dev": true + }, + "@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true + }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "idom-client-react": { + "version": "file:../../../src/idom/client/packages/idom-client-react", + "requires": { + "fast-json-patch": "^3.0.0-1", + "htm": "^3.0.3", + "jsdom": "16.3.0", + "prettier": "^2.2.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "uvu": "^0.5.1" + } + }, + "is-core-module": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "prettier": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", + "dev": true + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "rollup": { + "version": "2.52.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", + "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "rollup-plugin-commonjs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", + "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-node-resolve": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", + "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", + "dev": true, + "requires": { + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-replace": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", + "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", + "dev": true, + "requires": { + "magic-string": "^0.25.2", + "rollup-pluginutils": "^2.6.0" + } + }, + "rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1" + } + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + } + } +} diff --git a/docs/source/custom_js/package.json b/docs/source/custom_js/package.json new file mode 100644 index 000000000..c6686813a --- /dev/null +++ b/docs/source/custom_js/package.json @@ -0,0 +1,20 @@ +{ + "name": "idom-docs-example-loader", + "version": "1.0.0", + "description": "simple javascript client for IDOM's documentation", + "main": "index.js", + "scripts": { + "build": "rollup --config", + "format": "prettier --ignore-path .gitignore --write ." + }, + "devDependencies": { + "prettier": "^2.2.1", + "rollup": "^2.35.1", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0" + }, + "dependencies": { + "idom-client-react": "file:../../../src/idom/client/packages/idom-client-react" + } +} diff --git a/docs/source/custom_js/rollup.config.js b/docs/source/custom_js/rollup.config.js new file mode 100644 index 000000000..b8ca529bc --- /dev/null +++ b/docs/source/custom_js/rollup.config.js @@ -0,0 +1,25 @@ +import resolve from "rollup-plugin-node-resolve"; +import commonjs from "rollup-plugin-commonjs"; +import replace from "rollup-plugin-replace"; + +export default { + input: "src/index.js", + output: { + file: "../_static/custom.js", + format: "esm", + }, + plugins: [ + resolve(), + commonjs(), + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ], + onwarn: function (warning) { + if (warning.code === "THIS_IS_UNDEFINED") { + // skip warning where `this` is undefined at the top level of a module + return; + } + console.warn(warning.message); + }, +}; diff --git a/docs/source/_static/js/load-widget-example.js b/docs/source/custom_js/src/index.js similarity index 55% rename from docs/source/_static/js/load-widget-example.js rename to docs/source/custom_js/src/index.js index bcb43c99a..70d981a5a 100644 --- a/docs/source/_static/js/load-widget-example.js +++ b/docs/source/custom_js/src/index.js @@ -1,10 +1,11 @@ +import { mountLayoutWithWebSocket } from "idom-client-react"; + const LOC = window.location; const HTTP_PROTO = LOC.protocol; const WS_PROTO = HTTP_PROTO === "https:" ? "wss:" : "ws:"; const IDOM_MODULES_PATH = "/_modules"; -const IDOM_CLIENT_REACT_PATH = IDOM_MODULES_PATH + "/idom-client-react.js"; -export default function loadWidgetExample(idomServerHost, mountID, viewID) { +export function mountWidgetExample(mountID, viewID, idomServerHost) { const idom_url = "//" + (idomServerHost || LOC.host); const http_idom_url = HTTP_PROTO + idom_url; const ws_idom_url = WS_PROTO + idom_url; @@ -14,28 +15,22 @@ export default function loadWidgetExample(idomServerHost, mountID, viewID) { enableWidgetButton.appendChild(document.createTextNode("Enable Widget")); enableWidgetButton.setAttribute("class", "enable-widget-button"); - enableWidgetButton.addEventListener("click", () => { - { - import(http_idom_url + IDOM_CLIENT_REACT_PATH).then((module) => { - { - fadeOutAndThen(enableWidgetButton, () => { - { - mountEl.removeChild(enableWidgetButton); - mountEl.setAttribute("class", "interactive widget-container"); - module.mountLayoutWithWebSocket( - mountEl, - ws_idom_url + `/_idom/stream?view_id=${viewID}`, - (source, sourceType) => - loadImportSource(http_idom_url, source, sourceType) - ); - } - }); - } - }); - } - }); + enableWidgetButton.addEventListener("click", () => + fadeOutElementThenCallback(enableWidgetButton, () => { + { + mountEl.removeChild(enableWidgetButton); + mountEl.setAttribute("class", "interactive widget-container"); + mountLayoutWithWebSocket( + mountEl, + ws_idom_url + `/_idom/stream?view_id=${viewID}`, + (source, sourceType) => + loadImportSource(http_idom_url, source, sourceType) + ); + } + }) + ); - function fadeOutAndThen(element, callback) { + function fadeOutElementThenCallback(element, callback) { { var op = 1; // initial opacity var timer = setInterval(function () { @@ -60,7 +55,7 @@ export default function loadWidgetExample(idomServerHost, mountID, viewID) { function loadImportSource(baseUrl, source, sourceType) { if (sourceType == "NAME") { - return import(baseUrl + IDOM_MODULES_PATH + "/" + source + ".js"); + return import(baseUrl + IDOM_MODULES_PATH + "/" + source); } else { return import(source); } diff --git a/docs/source/examples/matplotlib_plot.py b/docs/source/examples/matplotlib_plot.py index 108353d85..ee255a19a 100644 --- a/docs/source/examples/matplotlib_plot.py +++ b/docs/source/examples/matplotlib_plot.py @@ -3,7 +3,7 @@ import matplotlib.pyplot as plt import idom -from idom.widgets.html import image +from idom.widgets import image @idom.component diff --git a/docs/source/examples/simple_dashboard.py b/docs/source/examples/simple_dashboard.py index d4bedcd38..ed302153e 100644 --- a/docs/source/examples/simple_dashboard.py +++ b/docs/source/examples/simple_dashboard.py @@ -3,10 +3,11 @@ import time import idom -from idom.widgets.html import Input +from idom.widgets import Input -victory = idom.install("victory", fallback="loading...") +victory = idom.web.module_from_template("react", "victory", fallback="loading...") +VictoryLine = idom.web.export(victory, "VictoryLine") @idom.component @@ -53,7 +54,7 @@ async def animate(): } set_data(data[1:] + [next_data_point]) - return victory.VictoryLine( + return VictoryLine( { "data": data, "style": { diff --git a/noxfile.py b/noxfile.py index f5c2348c6..33bed8756 100644 --- a/noxfile.py +++ b/noxfile.py @@ -60,7 +60,10 @@ def docs(session: Session) -> None: "scripts/live_docs.py", "--open-browser", # for some reason this matches absolute paths - "--ignore=**/docs/source/auto/*", + "--ignore=**/auto/*", + "--ignore=**/_static/custom.js", + "--ignore=**/node_modules/**/*", + "--ignore=**/package-lock.json", "-a", "-E", "-b", diff --git a/scripts/live_docs.py b/scripts/live_docs.py index 65cac6974..0f42a1ca6 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -11,6 +11,7 @@ from docs.main import IDOM_MODEL_SERVER_URL_PREFIX, make_app, make_component from idom.server.sanic import PerClientStateServer +from idom.testing import clear_idom_web_modules_dir # these environment variable are used in custom Sphinx extensions @@ -24,6 +25,7 @@ def wrap_builder(old_builder): # This is the bit that we're injecting to get the example components to reload too def new_builder(): [s.stop() for s in _running_idom_servers] + clear_idom_web_modules_dir() server = PerClientStateServer( make_component(), diff --git a/src/idom/client/.gitignore b/src/idom/client/.gitignore deleted file mode 100644 index f2e88ad45..000000000 --- a/src/idom/client/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -web_modules -build diff --git a/src/idom/client/package-lock.json b/src/idom/client/package-lock.json index 045bbd4f7..a04120515 100644 --- a/src/idom/client/package-lock.json +++ b/src/idom/client/package-lock.json @@ -2663,7 +2663,7 @@ }, "packages/idom-app-react/packages/idom-client-react": {}, "packages/idom-client-react": { - "version": "0.8.2", + "version": "0.8.3", "license": "MIT", "dependencies": { "fast-json-patch": "^3.0.0-1", @@ -2672,8 +2672,6 @@ "devDependencies": { "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" }, "peerDependencies": { @@ -3469,8 +3467,6 @@ "htm": "^3.0.3", "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" } }, diff --git a/src/idom/client/packages/idom-client-react/package-lock.json b/src/idom/client/packages/idom-client-react/package-lock.json index e590bff4f..72b4b3999 100644 --- a/src/idom/client/packages/idom-client-react/package-lock.json +++ b/src/idom/client/packages/idom-client-react/package-lock.json @@ -1,22 +1,20 @@ { "name": "idom-client-react", - "version": "0.7.4", + "version": "0.8.3", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.7.4", + "version": "0.8.3", "license": "MIT", "dependencies": { "fast-json-patch": "^3.0.0-1", - "htm": "^3.0.3" + "htm": "^3.0.3", + "preact": "^10.5.13" }, "devDependencies": { - "esm": "^3.2.25", "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" }, "peerDependencies": { @@ -297,15 +295,6 @@ "source-map": "~0.6.1" } }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -505,7 +494,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "peer": true }, "node_modules/jsbn": { "version": "0.1.1", @@ -623,7 +612,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -680,7 +669,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -714,6 +703,15 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "node_modules/preact": { + "version": "10.5.13", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.13.tgz", + "integrity": "sha512-q/vlKIGNwzTLu+jCcvywgGrt+H/1P/oIRSD6mV4ln3hmlC+Aa34C7yfPI4+5bzW8pONyVXYS7SvXosy2dKKtWQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -739,7 +737,7 @@ "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -774,7 +772,7 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", - "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -788,7 +786,7 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -803,7 +801,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "peer": true }, "node_modules/request": { "version": "2.88.2", @@ -950,7 +948,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", - "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -1447,12 +1445,6 @@ "source-map": "~0.6.1" } }, - "esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "dev": true - }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1610,7 +1602,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "peer": true }, "jsbn": { "version": "0.1.1", @@ -1708,7 +1700,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, + "peer": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -1750,7 +1742,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "peer": true }, "optionator": { "version": "0.8.3", @@ -1778,6 +1770,11 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "preact": { + "version": "10.5.13", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.13.tgz", + "integrity": "sha512-q/vlKIGNwzTLu+jCcvywgGrt+H/1P/oIRSD6mV4ln3hmlC+Aa34C7yfPI4+5bzW8pONyVXYS7SvXosy2dKKtWQ==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -1794,7 +1791,7 @@ "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, + "peer": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -1823,7 +1820,7 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", - "dev": true, + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -1834,7 +1831,7 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "dev": true, + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -1846,7 +1843,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "peer": true }, "request": { "version": "2.88.2", @@ -1954,7 +1951,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", - "dev": true, + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/src/idom/client/packages/idom-client-react/package.json b/src/idom/client/packages/idom-client-react/package.json index 845889d36..42ffc6d46 100644 --- a/src/idom/client/packages/idom-client-react/package.json +++ b/src/idom/client/packages/idom-client-react/package.json @@ -1,7 +1,7 @@ { "name": "idom-client-react", "description": "A client for IDOM implemented in React", - "version": "0.8.2", + "version": "0.8.3", "author": "Ryan Morshead", "license": "MIT", "type": "module", @@ -20,8 +20,6 @@ "devDependencies": { "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" }, "dependencies": { diff --git a/src/idom/client/packages/idom-client-react/src/component.js b/src/idom/client/packages/idom-client-react/src/component.js index b7fec67b3..4e4821be9 100644 --- a/src/idom/client/packages/idom-client-react/src/component.js +++ b/src/idom/client/packages/idom-client-react/src/component.js @@ -58,7 +58,12 @@ function ImportedElement({ model }) { react.useEffect(() => { if (fallback) { - reactDOM.unmountComponentAtNode(mountPoint.current); + importSource.then(() => { + reactDOM.unmountComponentAtNode(mountPoint.current); + if ( mountPoint.current.children ) { + mountPoint.current.removeChild(mountPoint.current.children[0]) + } + }); } }, []); @@ -87,7 +92,8 @@ function ImportedElement({ model }) { if (!fallback) { return html`
`; } else if (typeof fallback == "string") { - return html`
${fallback}
`; + // need the second div there so we can removeChild above + return html`
${fallback}
`; } else { return html`
<${StandardElement} model=${fallback} /> diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 924fa1c52..3a379c33a 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -33,6 +33,8 @@ logger = logging.getLogger(__name__) +_SERVER_COUNT = 0 + class Config(TypedDict, total=False): """Config for :class:`SanicRenderServer`""" @@ -153,6 +155,10 @@ def _setup_config_and_app( config: Optional[Config], app: Optional[Sanic], ) -> Tuple[Config, Sanic]: + if app is None: + global _SERVER_COUNT + _SERVER_COUNT += 1 + app = Sanic(f"{__name__}[{_SERVER_COUNT}]") return ( { "cors": False, @@ -161,7 +167,7 @@ def _setup_config_and_app( "redirect_root_to_index": True, **(config or {}), # type: ignore }, - app or Sanic(), + app, ) diff --git a/src/idom/testing.py b/src/idom/testing.py index f35117455..4d703867b 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -5,6 +5,7 @@ import logging import re +import shutil from functools import wraps from types import TracebackType from typing import ( @@ -25,6 +26,7 @@ from selenium.webdriver import Chrome from selenium.webdriver.remote.webdriver import WebDriver +from idom.config import IDOM_WED_MODULES_DIR from idom.core.events import EventHandler from idom.core.hooks import LifeCycleHook, current_hook from idom.core.utils import hex_id @@ -285,3 +287,8 @@ def use(self, function: Callable[..., Any]) -> EventHandler: self._handler.clear() self._handler.add(function) return self._handler + + +def clear_idom_web_modules_dir() -> None: + for path in IDOM_WED_MODULES_DIR.current.iterdir(): + shutil.rmtree(path) if path.is_dir() else path.unlink() diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 0e95c4b2f..4b7242878 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -5,6 +5,7 @@ from __future__ import annotations +import shutil from dataclasses import dataclass from functools import partial from pathlib import Path @@ -89,6 +90,7 @@ def module_from_file( fallback: Optional[Any] = None, resolve_exports: bool = IDOM_DEBUG_MODE.current, resolve_exports_depth: int = 5, + symlink: bool = False, ) -> WebModule: source_file = Path(file) target_file = _web_module_path(name) @@ -98,7 +100,10 @@ def module_from_file( raise FileExistsError(f"{name!r} already exists as {target_file.resolve()}") else: target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.symlink_to(source_file) + if symlink: + target_file.symlink_to(source_file) + else: + shutil.copy(source_file, target_file) return WebModule( source=name + module_name_suffix(name), source_type=NAME_SOURCE, diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 3826da0b7..7e7c62895 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -96,7 +96,7 @@ def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str] item.split(" as ", 1)[-1] for item in export.split(" from ")[0].strip("{}").split(",") ) - else: + elif not (export.startswith("function ") or export.startswith("class ")): logger.warning(f"Unknown export type {export!r}") return {n.strip() for n in names}, {r.strip() for r in references} diff --git a/tests/conftest.py b/tests/conftest.py index 2858e2279..228f2f4e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import inspect import os -import shutil from typing import Any, List import pytest @@ -12,8 +11,11 @@ from selenium.webdriver.support.ui import WebDriverWait import idom -from idom.config import IDOM_WED_MODULES_DIR -from idom.testing import ServerMountPoint, create_simple_selenium_web_driver +from idom.testing import ( + ServerMountPoint, + clear_idom_web_modules_dir, + create_simple_selenium_web_driver, +) def pytest_collection_modifyitems( @@ -106,11 +108,7 @@ def driver_is_headless(pytestconfig: Config): @pytest.fixture(autouse=True) def _clear_web_modules_dir_after_test(): - for path in IDOM_WED_MODULES_DIR.current.iterdir(): - if path.is_dir(): - shutil.rmtree(path) - else: - path.unlink() + clear_idom_web_modules_dir() def _mark_coros_as_async_tests(items: List[pytest.Item]) -> None: diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 9f95cab18..7e9b08200 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -49,7 +49,7 @@ def ShowCurrentComponent(): def test_module_from_url(driver): - app = Sanic() + app = Sanic(__name__) # instead of directing the URL to a CDN, we just point it to this static file app.static( @@ -130,6 +130,19 @@ def test_module_from_file_source_conflict(tmp_path): idom.web.module_from_file("temp", second_file) +def test_web_module_from_file_symlink(tmp_path): + file = tmp_path / "temp.js" + file.touch() + + module = idom.web.module_from_file("temp", file, symlink=True) + + assert module.file.resolve().read_text() == "" + + file.write_text("hello world!") + + assert module.file.resolve().read_text() == "hello world!" + + def test_module_missing_exports(): module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None) From fc0d3bce83fb24d9f22d8ab81a0580cf6dd63f84 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 18 Jun 2021 00:08:17 -0700 Subject: [PATCH 18/20] doc updates --- docs/source/_exts/build_custom_js.py | 3 +- docs/source/custom_js/package-lock.json | 6 +- docs/source/javascript-components.rst | 127 ++++++++++++------------ noxfile.py | 2 +- src/idom/web/module.py | 61 ++++++++++-- 5 files changed, 120 insertions(+), 79 deletions(-) diff --git a/docs/source/_exts/build_custom_js.py b/docs/source/_exts/build_custom_js.py index 233834d9c..c37e9847f 100644 --- a/docs/source/_exts/build_custom_js.py +++ b/docs/source/_exts/build_custom_js.py @@ -9,4 +9,5 @@ def setup(app: Sphinx) -> None: - subprocess.run(["npm", "run", "build"], cwd=CUSTOM_JS_DIR, shell=True) + subprocess.run("npm install", cwd=CUSTOM_JS_DIR, shell=True) + subprocess.run("npm run build", cwd=CUSTOM_JS_DIR, shell=True) diff --git a/docs/source/custom_js/package-lock.json b/docs/source/custom_js/package-lock.json index 4c648f3e9..8f9ce2b91 100644 --- a/docs/source/custom_js/package-lock.json +++ b/docs/source/custom_js/package-lock.json @@ -19,7 +19,7 @@ } }, "../../../src/idom/client/packages/idom-client-react": { - "version": "0.8.2", + "version": "0.8.3", "license": "MIT", "dependencies": { "fast-json-patch": "^3.0.0-1", @@ -28,8 +28,6 @@ "devDependencies": { "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" }, "peerDependencies": { @@ -327,8 +325,6 @@ "htm": "^3.0.3", "jsdom": "16.3.0", "prettier": "^2.2.1", - "react": "^16.13.1", - "react-dom": "^16.13.1", "uvu": "^0.5.1" } }, diff --git a/docs/source/javascript-components.rst b/docs/source/javascript-components.rst index ba6154379..b66a2c9ad 100644 --- a/docs/source/javascript-components.rst +++ b/docs/source/javascript-components.rst @@ -17,17 +17,39 @@ This can be accomplished in different ways for different reasons: * - Integration Method - Use Case + * - :ref:`Dynamically Loaded Components` + - You want to **quickly experiment** with IDOM and the Javascript ecosystem. + * - :ref:`Custom Javascript Components` - You want to create polished software that can be **easily shared** with others. - * - :ref:`Dynamically Install Javascript` (requires NPM_) - - You want to **quickly experiment** with IDOM and the Javascript ecosystem. + +Dynamically Loaded Components +----------------------------- + +.. note:: + + This method is not recommended in production systems - see + :ref:`Distributing Javascript Components` for more info. + +IDOM makes it easy to draft your code when you're in the early stages of development by +using a CDN_ to dynamically load Javascript packages on the fly. In this example we'll +be using the ubiquitous React-based UI framework `Material UI`_. + +.. example:: material_ui_button_no_action + +So now that we can display a Material UI Button we probably want to make it do +something. Thankfully there's nothing new to learn here, you can pass event handlers to +the button just as you did when :ref:`getting started`. Thus, all we need to do is add +an ``onClick`` handler to the component: + +.. example:: material_ui_button_on_click Custom Javascript Components ---------------------------- -For projects that will be shared with others we recommend bundling your Javascript with +For projects that will be shared with others, we recommend bundling your Javascript with `rollup `__ or `webpack `__ into a `web module `__. @@ -35,12 +57,13 @@ IDOM also provides a `template repository `__ that can be used as a blueprint to build a library of React components. -The core benefit of loading Javascript in this way is that users of your code won't need -to have NPM_ installed. Rather, they can use ``pip`` to install your Python package -without any other build steps because the bundled Javascript you distributed with it -will be symlinked into the IDOM client at runtime. +To work as intended, the Javascript bundle must provide named exports for the following +functions as well as any components that will be rendered. + +.. note:: -To work as intended, the Javascript bundle must export the following named functions: + The exported components do not have to be React-based since you'll have full control + over the rendering mechanism. .. code-block:: typescript @@ -63,25 +86,17 @@ These functions can be thought of as being analogous to those from React. .. |reactDOM.unmountComponentAtNode| replace:: ``reactDOM.unmountComponentAtNode`` .. _reactDOM.unmountComponentAtNode: https://reactjs.org/docs/react-api.html#createelement -And will be called in the following manner: +And will be called in the following manner, where ``component`` is a named export of +your module: .. code-block:: // on every render - renderElement(createElement(type, props), container); + renderElement(createElement(component, props), container); // on unmount unmountElement(container); -Once you've done this, you can distribute the bundled javascript in your Python package -and integrate it into IDOM by defining :class:`~idom.client.module.Module` objects that -load them from source: - -.. code-block:: - - import idom - my_js_package = idom.Module("my-js-package", source_file="/path/to/my/bundle.js") - -The simplest way to try this out yourself though, is to hook in simple a hand-crafted +The simplest way to try this out yourself though, is to hook in a simple hand-crafted Javascript module that has the requisite interface. In the example to follow we'll create a very basic SVG line chart. The catch though is that we are limited to using Javascript that can run directly in the browser. This means we can't use fancy syntax @@ -91,58 +106,40 @@ like `JSX `__ and instead will us .. example:: super_simple_chart -.. Links -.. ===== +Distributing Javascript Components +---------------------------------- -.. _Material UI: https://material-ui.com/ -.. _NPM: https://www.npmjs.com -.. _install NPM: https://www.npmjs.com/get-npm - - - -Dynamically Install Javascript ------------------------------- - -.. warning:: - - - Before continuing `install NPM`_. - - Not guaranteed to work in all client implementations - (see :attr:`~idom.config.IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT`) - -IDOM makes it easy to draft your code when you're in the early stages of development by -using NPM_ to directly install Javascript packages on the fly. In this example we'll be -using the ubiquitous React-based UI framework `Material UI`_ which can be installed -using the ``idom`` CLI: - -.. code-block:: bash - - idom install @material-ui/core +There are two ways that you can distribute your :ref:`Custom Javascript Components`: -Or at runtime with :func:`idom.client.module.install` (this is useful if you're working -in a REPL or Jupyter Notebook): +- In a Python package via PyPI_ +- Using a CDN_ -.. code-block:: - - import idom - material_ui = idom.install("@material-ui/core") - # or install multiple modules at once - material_ui, *other_modules = idom.install(["@material-ui/core", ...]) - -.. note:: +That can be subsequently loaded using the respective functions: - Any standard javascript dependency specifier is allowed here. +- :func:`~idom.web.module.module_from_file` +- :func:`~idom.web.module.module_from_url` -Once the package has been successfully installed, you can import and display the component: +These options are not mutually exclusive though - if you upload your Javascript +components to NPM_ and also bundle your Javascript inside a Python package, in principle +your users can determine which option work best for them. Regardless though, either you +or, if you give then the choice, your users, will have to consider the tradeoffs of +either approach. -.. example:: material_ui_button_no_action +- Distribution via PyPI_ - This method is ideal for local usage since the user can + server all the Javascript components they depend on from their computer without + requiring a network connection. +- Distribution via a CDN_ - Most useful in production-grade applications where its assumed + the user has a network connection. In this scenario a CDN's + `edge network `__ can be used to bring + the Javascript source closer to the user in order to reduce page load times. -Passing Props To Javascript Components --------------------------------------- -So now that we can install and display a Material UI Button we probably want to make it -do something. Thankfully there's nothing new to learn here, you can pass event handlers -to the button just as you did when :ref:`getting started`. Thus, all we need to do is -add an ``onClick`` handler to the component: +.. Links +.. ===== -.. example:: material_ui_button_on_click +.. _Material UI: https://material-ui.com/ +.. _NPM: https://www.npmjs.com +.. _install NPM: https://www.npmjs.com/get-npm +.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network +.. _PyPI: https://pypi.org/ diff --git a/noxfile.py b/noxfile.py index 33bed8756..fb880cf24 100644 --- a/noxfile.py +++ b/noxfile.py @@ -62,7 +62,7 @@ def docs(session: Session) -> None: # for some reason this matches absolute paths "--ignore=**/auto/*", "--ignore=**/_static/custom.js", - "--ignore=**/node_modules/**/*", + "--ignore=**/node_modules/*", "--ignore=**/package-lock.json", "-a", "-E", diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 4b7242878..d60012306 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -27,7 +27,7 @@ """A named souce - usually a Javascript package name""" URL_SOURCE = SourceType("URL") -"""A source loaded from a URL, usually from a CDN""" +"""A source loaded from a URL, usually a CDN""" def module_from_url( @@ -36,6 +36,19 @@ def module_from_url( resolve_exports: bool = IDOM_DEBUG_MODE.current, resolve_exports_depth: int = 5, ) -> WebModule: + """Load a :class:`WebModule` from a :data:`URL_SOURCE` + + Parameters: + url: + Where the javascript module will be loaded from which conforms to the + interface for :ref:`Custom Javascript Components` + fallback: + What to temporarilly display while the module is being loaded. + resolve_imports: + Whether to try and find all the named exports of this module. + resolve_exports_depth: + How deeply to search for those exports. + """ return WebModule( source=url, source_type=URL_SOURCE, @@ -51,33 +64,50 @@ def module_from_url( def module_from_template( template: str, - name: str, + package: str, cdn: str = "https://esm.sh", fallback: Optional[Any] = None, resolve_exports: bool = IDOM_DEBUG_MODE.current, resolve_exports_depth: int = 5, ) -> WebModule: + """Load a :class:`WebModule` from a :data:`URL_SOURCE` using a known framework + + Parameters: + template: + The name of the template to use with the given ``package`` (``react`` | ``preact``) + package: + The name of a package to load. May include a file extension (defaults to + ``.js`` if not given) + cdn: + Where the package should be loaded from. The CDN must distribute ESM modules + fallback: + What to temporarilly display while the module is being loaded. + resolve_imports: + Whether to try and find all the named exports of this module. + resolve_exports_depth: + How deeply to search for those exports. + """ cdn = cdn.rstrip("/") - template_file_name = f"{template}{module_name_suffix(name)}" + template_file_name = f"{template}{module_name_suffix(package)}" template_file = Path(__file__).parent / "templates" / template_file_name if not template_file.exists(): raise ValueError(f"No template for {template_file_name!r} exists") - target_file = _web_module_path(name) + target_file = _web_module_path(package) if not target_file.exists(): target_file.parent.mkdir(parents=True, exist_ok=True) target_file.write_text( - template_file.read_text().replace("$PACKAGE", name).replace("$CDN", cdn) + template_file.read_text().replace("$PACKAGE", package).replace("$CDN", cdn) ) return WebModule( - source=name + module_name_suffix(name), + source=package + module_name_suffix(package), source_type=NAME_SOURCE, default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_url(f"{cdn}/{name}", resolve_exports_depth) + resolve_module_exports_from_url(f"{cdn}/{package}", resolve_exports_depth) if resolve_exports else None ), @@ -92,6 +122,23 @@ def module_from_file( resolve_exports_depth: int = 5, symlink: bool = False, ) -> WebModule: + """Load a :class:`WebModule` from a :data:`URL_SOURCE` using a known framework + + Parameters: + template: + The name of the template to use with the given ``package`` + package: + The name of a package to load. May include a file extension (defaults to + ``.js`` if not given) + cdn: + Where the package should be loaded from. The CDN must distribute ESM modules + fallback: + What to temporarilly display while the module is being loaded. + resolve_imports: + Whether to try and find all the named exports of this module. + resolve_exports_depth: + How deeply to search for those exports. + """ source_file = Path(file) target_file = _web_module_path(name) if not source_file.exists(): From fb657edb11e780e6eaaabe96bab87baaa71d3c03 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 19 Jun 2021 23:43:42 -0700 Subject: [PATCH 19/20] update docs and export more from idom-client-react --- docs/source/_static/custom.js | 47 ++- docs/source/command-line.rst | 51 ---- docs/source/faq.rst | 10 +- docs/source/index.rst | 2 +- docs/source/javascript-components.rst | 276 +++++++++++++++++- .../idom-client-react/src/component.js | 53 ++-- src/idom/web/module.py | 12 + 7 files changed, 321 insertions(+), 130 deletions(-) delete mode 100644 docs/source/command-line.rst diff --git a/docs/source/_static/custom.js b/docs/source/_static/custom.js index 5d1658510..ac042872d 100644 --- a/docs/source/_static/custom.js +++ b/docs/source/_static/custom.js @@ -1608,6 +1608,21 @@ function Element({ model, key }) { } } +function elementChildren(modelChildren) { + if (!modelChildren) { + return []; + } else { + return modelChildren.map((child) => { + switch (typeof child) { + case "object": + return html`<${Element} key=${child.key} model=${child} />`; + case "string": + return child; + } + }); + } +} + function StandardElement({ model }) { const config = react.useContext(LayoutConfigContext); const children = elementChildren(model.children); @@ -1632,7 +1647,7 @@ function ImportedElement({ model }) { if (fallback) { importSource.then(() => { reactDom.unmountComponentAtNode(mountPoint.current); - if ( mountPoint.current.children ) { + if (mountPoint.current.children) { mountPoint.current.removeChild(mountPoint.current.children[0]); } }); @@ -1673,21 +1688,6 @@ function ImportedElement({ model }) { } } -function elementChildren(modelChildren) { - if (!modelChildren) { - return []; - } else { - return modelChildren.map((child) => { - switch (typeof child) { - case "object": - return html`<${Element} key=${child.key} model=${child} />`; - case "string": - return child; - } - }); - } -} - function elementAttributes(model, sendEvent) { const attributes = Object.assign({}, model.attributes); @@ -1736,22 +1736,13 @@ function loadFromImportSource(config, importSource) { typeof module.unmountElement == "function" ) { return { - createElement: (type, props) => - module.createElement(module[type], props), + createElement: (type, props, children) => + module.createElement(module[type], props, children, config), renderElement: module.renderElement, unmountElement: module.unmountElement, }; } else { - return { - createElement: (type, props, children) => - react.createElement( - module[type], - props, - ...elementChildren(children) - ), - renderElement: reactDom.render, - unmountElement: reactDom.unmountComponentAtNode, - }; + console.error(`${module} does not expose the required interfaces`); } }); } diff --git a/docs/source/command-line.rst b/docs/source/command-line.rst deleted file mode 100644 index 75e766cfc..000000000 --- a/docs/source/command-line.rst +++ /dev/null @@ -1,51 +0,0 @@ -Command-line -============ - -IDOM supplies a CLI for: - -- Displaying version information -- Installing Javascript packages -- Restoring IDOM's client - - -Show Version Info ------------------ - -To see the version of ``idom`` being run: - -.. code-block:: bash - - idom version - -You can also show all available versioning information: - -.. code-block:: bash - - idom version --verbose - -This is useful for bug reports. - - -Install Javascript Packages ---------------------------- - -You can install Javascript packages at the command line rather than doing it -:ref:`programmatically `: - -.. code-block:: bash - - idom install some-js-package versioned-js-package@^1.0.0 - -If the package is already installed then the build will be skipped. - - -Restore The Client ------------------- - -Replace IDOM's client with a backup from its original installation. - -.. code-block:: bash - - idom restore - -This is useful if a build of the client fails and leaves it in an unusable state. diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 135ebd53e..10c6abb7c 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -24,11 +24,11 @@ simple and one which allows you to do everything you normally would in Python. Does IDOM support any React component? -------------------------------------- -If you :ref:`Dynamically Install Javascript` components, then the answer is no. Only -components whose props are JSON serializable, or which expect basic callback functions -similar to those of standard event handlers (e.g. ``onClick``) will operate as expected. +If you use :ref:`Dynamically Loaded Components`, then the answer is no. Only components +whose props are JSON serializable, or which expect basic callback functions similar to +those of standard event handlers (e.g. ``onClick``) will operate as expected. -However, if you import a pre-built :ref:`Custom Javascript Component ` +However, if you import a :ref:`Custom Javascript Component ` then, so long as the bundle has be defined appropriately, any component can be made to work, even those that don't rely on React. @@ -54,6 +54,8 @@ These restrictions apply because the Javascript from the CDN must be able to run natively in the browser and the module must be able to run in isolation from the main application. +See :ref:`Distributing Javascript via CDN_` for more info. + What props can I pass to Javascript components? ----------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index a7d8475c5..8686a7f49 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,7 +17,6 @@ IDOM core-concepts javascript-components - command-line specifications .. toctree:: @@ -41,6 +40,7 @@ IDOM A package for building highly interactive user interfaces in pure Python inspred by `ReactJS `__. + At a Glance ----------- diff --git a/docs/source/javascript-components.rst b/docs/source/javascript-components.rst index b66a2c9ad..6c2aa38d4 100644 --- a/docs/source/javascript-components.rst +++ b/docs/source/javascript-components.rst @@ -4,12 +4,10 @@ Javascript Components While IDOM is a great tool for displaying HTML and responding to browser events with pure Python, there are other projects which already allow you to do this inside `Jupyter Notebooks `__ -or in -`webpages `__. +or in standard +`web apps `__. The real power of IDOM comes from its ability to seamlessly leverage the existing -ecosystem of -`React components `__. -This can be accomplished in different ways for different reasons: +Javascript ecosystem. This can be accomplished in different ways for different reasons: .. list-table:: :header-rows: 1 @@ -50,11 +48,7 @@ Custom Javascript Components ---------------------------- For projects that will be shared with others, we recommend bundling your Javascript with -`rollup `__ or `webpack `__ -into a -`web module `__. -IDOM also provides a -`template repository `__ +Rollup_ or Webpack_ into a `web module`_. IDOM also provides a `template repository`_ that can be used as a blueprint to build a library of React components. To work as intended, the Javascript bundle must provide named exports for the following @@ -114,11 +108,6 @@ There are two ways that you can distribute your :ref:`Custom Javascript Componen - In a Python package via PyPI_ - Using a CDN_ -That can be subsequently loaded using the respective functions: - -- :func:`~idom.web.module.module_from_file` -- :func:`~idom.web.module.module_from_url` - These options are not mutually exclusive though - if you upload your Javascript components to NPM_ and also bundle your Javascript inside a Python package, in principle your users can determine which option work best for them. Regardless though, either you @@ -135,6 +124,255 @@ either approach. the Javascript source closer to the user in order to reduce page load times. +Distributing Javascript via PyPI_ +................................. + +This can be most easily accomplished by using the `template repository`_ that's been +purpose-built for this. However, to get a better sense for its inner workings, we'll +briefly look at what's required. At a high level, we must consider how to... + +1. bundle your Javascript into an `ECMAScript Module`) +2. include that Javascript bundle in a Python package +3. use it as a component in your applciation using IDOM + +In the descriptions to follow we'll be assuming that: + +- NPM_ is the Javascript package manager +- The components are implemented with React_ +- Rollup_ bundles the Javascript module +- Setuptools_ builds the Python package + +To start, let's take a look at the file structure we'll be building: + +.. code-block:: text + + your-project + |-- js + | |-- src + | | \-- index.js + | |-- package.json + | \-- rollup.config.js + |-- your_python_package + | |-- __init__.py + | \-- widget.py + |-- Manifest.in + |-- pyproject.toml + \-- setup.py + +``index.js`` should contain the relevant exports (see +:ref:`Custom JavaScript Components` for more info): + +.. code-block:: javascript + + import * as react from "react"; + import * as reactDOM from "react-dom"; + + // exports required to interface with IDOM + export const createElement = (component, props) => + react.createElement(component, props); + export const renderElement = reactDOM.render; + export const unmountElement = reactDOM.unmountComponentAtNode; + + // exports for your components + export YourFirstComponent(props) {...}; + export YourSecondComponent(props) {...}; + export YourThirdComponent(props) {...}; + + +Your ``package.json`` should include the following: + +.. code-block:: python + + { + "name": "YOUR-PACKAGE-NAME", + "scripts": { + "build": "rollup --config", + ... + }, + "devDependencies": { + "rollup": "^2.35.1", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-replace": "^2.2.0", + ... + }, + "dependencies": { + "react": "^17.0.1", + "react-dom": "^17.0.1", + ... + }, + ... + } + +Getting a bit more in the weeds now, your ``rollup.config.js`` file should be designed +such that it drops an ES Module at ``your-project/your_python_package/bundle.js`` since +we'll be writing ``widget.py`` under that assumption. + +.. note:: + + Don't forget to ignore this ``bundle.js`` file when committing code (with a + ``.gitignore`` if you're using Git) since it can always rebuild from the raw + Javascript source in ``your-project/js``. + +.. code-block:: javascript + + import resolve from "rollup-plugin-node-resolve"; + import commonjs from "rollup-plugin-commonjs"; + import replace from "rollup-plugin-replace"; + + export default { + input: "src/index.js", + output: { + file: "../your_python_package/bundle.js", + format: "esm", + }, + plugins: [ + resolve(), + commonjs(), + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ] + }; + +Your ``widget.py`` file should then load the neighboring bundle file using +:func:`~idom.web.module.module_from_file`. Then components from that bundle can be +loaded with :func:`~idom.web.module.export`. + +.. code-block:: + + from pathlib import Path + + import idom + + _BUNDLE_PATH = Path(__file__).parent / "bundle.js" + _WEB_MODULE = idom.web.module_from_file( + # Note that this is the same name from package.json - this must be globally + # unique since it must share a namespace with all other javascript packages. + name="YOUR-PACKAGE-NAME", + file=_BUNDLE_PATH, + # What to temporarilly display while the module is being loaded + fallback="Loading...", + ) + + # Your module must provide a named export for YourFirstComponent + YourFirstComponent = idom.web.export(_WEB_MODULE, "YourFirstComponent") + + # It's possible to export multiple components at once + YourSecondComponent, YourThirdComponent = idom.web.export( + _WEB_MODULE, ["YourSecondComponent", "YourThirdComponent"] + ) + +.. note:: + + When :data:`idom.config.IDOM_DEBUG_MODE` is active, named exports will be validated. + +The remaining files that we need to create are concerned with creating a Python package. +We won't cover all the details here, so refer to the Setuptools_ documentation for +more information. With that said, the first file to fill out is `pyproject.toml` since +we need to declare what our build tool is (in this case Setuptools): + +.. code-block:: toml + + [build-system] + requires = ["setuptools>=40.8.0", "wheel"] + build-backend = "setuptools.build_meta" + +Then, we can creat the ``setup.py`` file which uses Setuptools. This will differ +substantially from a normal ``setup.py`` file since, as part of the build process we'll +need to use NPM to bundle our Javascript. This requires customizing some of the build +commands in Setuptools like ``build``, ``sdist``, and ``develop``: + +.. code-block:: python + + import subprocess + from pathlib import Path + + from setuptools import setup, find_packages + from distutils.command.build import build + from distutils.command.sdist import sdist + from setuptools.command.develop import develop + + PACKAGE_SPEC = {} # gets passed to setup() at the end + + + # ----------------------------------------------------------------------------- + # General Package Info + # ----------------------------------------------------------------------------- + + + PACKAGE_NAME = "your_python_package" + + PACKAGE_SPEC.update( + name=PACKAGE_NAME, + version="0.0.1", + packages=find_packages(exclude=["tests*"]), + classifiers=["Framework :: IDOM", ...], + keywords=["IDOM", "components", ...], + # install IDOM with this package + install_requires=["idom"], + # required in order to include static files like bundle.js using MANIFEST.in + include_package_data=True, + # we need access to the file system, so cannot be run from a zip file + zip_safe=False, + ) + + + # ---------------------------------------------------------------------------- + # Build Javascript + # ---------------------------------------------------------------------------- + + + # basic paths used to gather files + PROJECT_ROOT = Path(__file__).parent + PACKAGE_DIR = PROJECT_ROOT / PACKAGE_NAME + JS_DIR = PROJECT_ROOT / "js" + + + def build_javascript_first(cls): + class Command(cls): + def run(self): + for cmd_str in ["npm install", "npm run build"]: + subprocess.run(cmd_str.split(), cwd=str(JS_DIR), check=True) + super().run() + + return Command + + + package["cmdclass"] = { + "sdist": build_javascript_first(sdist), + "build": build_javascript_first(build), + "develop": build_javascript_first(develop), + } + + + # ----------------------------------------------------------------------------- + # Run It + # ----------------------------------------------------------------------------- + + + if __name__ == "__main__": + setup(**package) + + +Finally, since we're using ``include_package_data`` you'll need a MANIFEST.in_ file that +includes ``bundle.js``: + +.. code-block:: text + + include your_python_package/bundle.js + +And that's it! While this might seem like a lot of work, you're always free to start +creating your custom components using the provided `template repository`_ so you can get +up and running as quickly as possible. + + +Distributing Javascript via CDN_ +................................ + +Under construction... + + .. Links .. ===== @@ -143,3 +381,11 @@ either approach. .. _install NPM: https://www.npmjs.com/get-npm .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network .. _PyPI: https://pypi.org/ +.. _template repository: https://github.com/idom-team/idom-react-component-cookiecutter +.. _web module: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules +.. _Rollup: https://rollupjs.org/guide/en/ +.. _Webpack: https://webpack.js.org/ +.. _Setuptools: https://setuptools.readthedocs.io/en/latest/userguide/index.html +.. _ECMAScript Module: https://tc39.es/ecma262/#sec-modules +.. _React: https://reactjs.org +.. _MANIFEST.in: https://packaging.python.org/guides/using-manifest-in/ diff --git a/src/idom/client/packages/idom-client-react/src/component.js b/src/idom/client/packages/idom-client-react/src/component.js index 4e4821be9..9217985d6 100644 --- a/src/idom/client/packages/idom-client-react/src/component.js +++ b/src/idom/client/packages/idom-client-react/src/component.js @@ -7,7 +7,7 @@ import serializeEvent from "./event-to-object"; import { applyPatchInplace, joinUrl } from "./utils"; const html = htm.bind(react.createElement); -const LayoutConfigContext = react.createContext({ +export const LayoutConfigContext = react.createContext({ sendEvent: undefined, loadImportSource: undefined, }); @@ -28,7 +28,7 @@ export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { } } -function Element({ model, key }) { +export function Element({ model, key }) { if (model.importSource) { return html`<${ImportedElement} model=${model} />`; } else { @@ -36,6 +36,21 @@ function Element({ model, key }) { } } +export function elementChildren(modelChildren) { + if (!modelChildren) { + return []; + } else { + return modelChildren.map((child) => { + switch (typeof child) { + case "object": + return html`<${Element} key=${child.key} model=${child} />`; + case "string": + return child; + } + }); + } +} + function StandardElement({ model }) { const config = react.useContext(LayoutConfigContext); const children = elementChildren(model.children); @@ -60,8 +75,8 @@ function ImportedElement({ model }) { if (fallback) { importSource.then(() => { reactDOM.unmountComponentAtNode(mountPoint.current); - if ( mountPoint.current.children ) { - mountPoint.current.removeChild(mountPoint.current.children[0]) + if (mountPoint.current.children) { + mountPoint.current.removeChild(mountPoint.current.children[0]); } }); } @@ -101,21 +116,6 @@ function ImportedElement({ model }) { } } -function elementChildren(modelChildren) { - if (!modelChildren) { - return []; - } else { - return modelChildren.map((child) => { - switch (typeof child) { - case "object": - return html`<${Element} key=${child.key} model=${child} />`; - case "string": - return child; - } - }); - } -} - function elementAttributes(model, sendEvent) { const attributes = Object.assign({}, model.attributes); @@ -164,22 +164,13 @@ function loadFromImportSource(config, importSource) { typeof module.unmountElement == "function" ) { return { - createElement: (type, props) => - module.createElement(module[type], props), + createElement: (type, props, children) => + module.createElement(module[type], props, children, config), renderElement: module.renderElement, unmountElement: module.unmountElement, }; } else { - return { - createElement: (type, props, children) => - react.createElement( - module[type], - props, - ...elementChildren(children) - ), - renderElement: reactDOM.render, - unmountElement: reactDOM.unmountComponentAtNode, - }; + console.error(`${module} does not expose the required interfaces`); } }); } diff --git a/src/idom/web/module.py b/src/idom/web/module.py index d60012306..d6ed49117 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -199,6 +199,18 @@ def export( fallback: Optional[Any] = None, allow_children: bool = True, ) -> Union[VdomDictConstructor, List[VdomDictConstructor]]: + """Return one or more VDOM constructors from a :class:`WebModule` + + Parameters: + export_names: + One or more names to export. If given as a string, a single component + will be returned. If a list is given, then a list of components will be + returned. + fallback: + What to temporarilly display while the module is being loaded. + allow_children: + Whether or not these components can have children. + """ if isinstance(export_names, str): if ( web_module.export_names is not None From 5b0048688e6937fa8645c1134e62eecd2c2ab52c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 20 Jun 2021 00:12:54 -0700 Subject: [PATCH 20/20] more docs --- docs/source/javascript-components.rst | 53 +++++++++++++++++---------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/source/javascript-components.rst b/docs/source/javascript-components.rst index 6c2aa38d4..e9debc6e6 100644 --- a/docs/source/javascript-components.rst +++ b/docs/source/javascript-components.rst @@ -105,23 +105,44 @@ Distributing Javascript Components There are two ways that you can distribute your :ref:`Custom Javascript Components`: -- In a Python package via PyPI_ - Using a CDN_ +- In a Python package via PyPI_ + +These options are not mutually exclusive though, and it may be beneficial to support +both options. For example, if you upload your Javascript components to NPM_ and also +bundle your Javascript inside a Python package, in principle your users can determine +which work best for them. Regardless though, either you or, if you give then the choice, +your users, will have to consider the tradeoffs of either approach. + +- :ref:`Distributing Javascript via CDN_` - Most useful in production-grade applications + where its assumed the user has a network connection. In this scenario a CDN's `edge + network `__ can be used to bring the + Javascript source closer to the user in order to reduce page load times. + +- :ref:`Distributing Javascript via PyPI_` - This method is ideal for local usage since + the user can server all the Javascript components they depend on from their computer + without requiring a network connection. + + +Distributing Javascript via CDN_ +................................ -These options are not mutually exclusive though - if you upload your Javascript -components to NPM_ and also bundle your Javascript inside a Python package, in principle -your users can determine which option work best for them. Regardless though, either you -or, if you give then the choice, your users, will have to consider the tradeoffs of -either approach. +Under this approach, to simplify these instructions, we're going to ignore the problem +of distributing the Javascript since that must be handled by your CDN. For open source +or personal projects, a CDN like https://unpkg.com/ makes things easy by automatically +preparing any package that's been uploaded to NPM_. If you need to roll with your own +private CDN, this will likely be more complicated. -- Distribution via PyPI_ - This method is ideal for local usage since the user can - server all the Javascript components they depend on from their computer without - requiring a network connection. +In either case though, on the Python side, things are quite simple. You need only pass +the URL where your package can be found to :func:`~idom.web.module.module_from_file` +where you can then load any of its exports: -- Distribution via a CDN_ - Most useful in production-grade applications where its assumed - the user has a network connection. In this scenario a CDN's - `edge network `__ can be used to bring - the Javascript source closer to the user in order to reduce page load times. +.. code-block:: + + import idom + + your_module = ido.web.module_from_file("https://some.cdn/your-module") + YourComponent = idom.web.export(your_module, "YourComponent") Distributing Javascript via PyPI_ @@ -367,12 +388,6 @@ creating your custom components using the provided `template repository`_ so you up and running as quickly as possible. -Distributing Javascript via CDN_ -................................ - -Under construction... - - .. Links .. =====