diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..356385d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +end_of_line = lf + +[*.py] +indent_size = 4 +max_line_length = 120 + +[*.md] +indent_size = 4 + +[*.html] +max_line_length = off + +[*.js] +max_line_length = off + +[*.css] +indent_size = 4 +max_line_length = off + +# Tests can violate line width restrictions in the interest of clarity. +[**/test_*.py] +max_line_length = off diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f26793..213f18a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,67 +12,51 @@ name: "CodeQL" on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - # Runs at 22:21 on Monday. - - cron: '21 22 * * 1' + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + # Runs at 22:21 on Monday. + - cron: "21 22 * * 1" jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # âšī¸ Command-line programs to run using the OS shell. - # đ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript", "python"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 6b1d4de..95d98da 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -7,10 +7,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yaml b/.github/workflows/publish-py.yaml index 34ae5fa..d7437d7 100644 --- a/.github/workflows/publish-py.yaml +++ b/.github/workflows/publish-py.yaml @@ -11,9 +11,9 @@ jobs: publish-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 6fc3233..a98e986 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index d5f5052..7110bc4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -1,37 +1,39 @@ name: Test on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * *" + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - with: - python-version: 3.x - - name: Check docs build - run: | - pip install -r requirements/build-docs.txt - linkcheckMarkdown docs/ -v -r - linkcheckMarkdown README.md -v -r - linkcheckMarkdown CHANGELOG.md -v -r - cd docs - mkdocs build --strict - - name: Check docs examples - run: | - pip install -r requirements/check-types.txt - pip install -r requirements/check-style.txt - mypy --show-error-codes docs/examples/python/ - black docs/examples/python/ --check - ruff check docs/examples/python/ + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: | + pip install -r requirements/build-docs.txt + pip install -r requirements/check-types.txt + pip install -r requirements/check-style.txt + pip install -e . + - name: Check docs build + run: | + linkcheckMarkdown docs/ -v -r + linkcheckMarkdown README.md -v -r + linkcheckMarkdown CHANGELOG.md -v -r + cd docs + mkdocs build --strict + - name: Check docs examples + run: | + mypy --show-error-codes docs/examples/python/ + ruff check docs/examples/python/ diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index b5ae7d0..df93152 100644 --- a/.github/workflows/test-src.yaml +++ b/.github/workflows/test-src.yaml @@ -17,9 +17,9 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies @@ -29,9 +29,9 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Latest Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Python Dependencies diff --git a/.gitignore b/.gitignore index 9155bda..12271fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ docs/site # --- JAVASCRIPT BUNDLES --- -src/reactpy_router/bundle.js +src/reactpy_router/static/bundle.js # --- PYTHON IGNORE FILES ---- @@ -108,7 +108,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..32ad81f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "proseWrap": "never", + "trailingComma": "all" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c673a2f..7380865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,31 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- Bump GitHub workflows +- Rename `use_query` to `use_search_params`. +- Rename `simple.router` to `browser_router`. +- Rename `SimpleResolver` to `StarletteResolver`. +- Rename `CONVERSION_TYPES` to `CONVERTERS`. +- Change "Match Any" syntax from a star `*` to `{name:any}`. +- Rewrite `reactpy_router.link` to be a server-side component. +- Simplified top-level exports within `reactpy_router`. + +### Added + +- New error for ReactPy router elements being used outside router context. +- Configurable/inheritable `Resolver` base class. +- Add debug log message for when there are no router matches. +- Add slug as a supported type. + +### Fixed + +- Fix bug where changing routes could cause render failure due to key identity. +- Fix bug where "Match Any" pattern wouldn't work when used in complex or nested paths. +- Fix bug where `link` elements could not have `@component` type children. +- Fix bug where the ReactPy would not detect the current URL after a reconnection. +- Fixed flakey tests on GitHub CI by adding click delays. ## [0.1.1] - 2023-12-13 diff --git a/MANIFEST.in b/MANIFEST.in index bdca1f4..71f4855 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include src/reactpy_router/bundle.js +recursive-include src/reactpy_router/static * include src/reactpy_router/py.typed diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py index 8ddbebb..32bb31e 100644 --- a/docs/examples/python/basic-routing-more-routes.py +++ b/docs/examples/python/basic-routing-more-routes.py @@ -1,14 +1,13 @@ from reactpy import component, html, run - -from reactpy_router import route, simple +from reactpy_router import browser_router, route @component def root(): - return simple.router( + return browser_router( route("/", html.h1("Home Page đ ")), route("/messages", html.h1("Messages đŦ")), - route("*", html.h1("Missing Link đâđĨ")), + route("{404:any}", html.h1("Missing Link đâđĨ")), ) diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py index 57b7a37..43c4e65 100644 --- a/docs/examples/python/basic-routing.py +++ b/docs/examples/python/basic-routing.py @@ -1,13 +1,12 @@ from reactpy import component, html, run - -from reactpy_router import route, simple +from reactpy_router import browser_router, route @component def root(): - return simple.router( + return browser_router( route("/", html.h1("Home Page đ ")), - route("*", html.h1("Missing Link đâđĨ")), + route("{404:any}", html.h1("Missing Link đâđĨ")), ) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index f03a692..01ffb18 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -1,7 +1,8 @@ from typing import TypedDict from reactpy import component, html, run -from reactpy_router import link, route, simple + +from reactpy_router import browser_router, link, route message_data: list["MessageDataType"] = [ {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, @@ -17,7 +18,7 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", @@ -26,7 +27,7 @@ def root(): route("/with/Alice", messages_with("Alice")), route("/with/Alice-Bob", messages_with("Alice", "Bob")), ), - route("*", html.h1("Missing Link đâđĨ")), + route("{404:any}", html.h1("Missing Link đâđĨ")), ) @@ -34,39 +35,32 @@ def root(): def home(): return html.div( html.h1("Home Page đ "), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + + messages = [] + for msg in last_messages.values(): + _link = link( + {"to": f"/messages/with/{'-'.join(msg['with'])}"}, + f"Conversation with: {', '.join(msg['with'])}", + ) + msg_from = f"{'' if msg['from'] is None else 'đ´'} {msg['message']}" + messages.append(html.li({"key": msg["id"]}, html.p(_link), msg_from)) + return html.div( html.h1("All Messages đŦ"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - html.p( - link( - f"Conversation with: {', '.join(msg['with'])}", - to=f"/messages/with/{'-'.join(msg['with'])}", - ), - ), - f"{'' if msg['from'] is None else 'đ´'} {msg['message']}", - ) - for msg in last_messages.values() - ] - ), + html.ul(messages), ) @component def messages_with(*names): - messages = [msg for msg in message_data if set(msg["with"]) == names] + messages = [msg for msg in message_data if tuple(msg["with"]) == names] return html.div( html.h1(f"Messages with {', '.join(names)} đŦ"), html.ul( diff --git a/docs/examples/python/route-links.py b/docs/examples/python/route-links.py index f2be305..baf428c 100644 --- a/docs/examples/python/route-links.py +++ b/docs/examples/python/route-links.py @@ -1,14 +1,14 @@ from reactpy import component, html, run -from reactpy_router import link, route, simple +from reactpy_router import browser_router, link, route @component def root(): - return simple.router( + return browser_router( route("/", home()), route("/messages", html.h1("Messages đŦ")), - route("*", html.h1("Missing Link đâđĨ")), + route("{404:any}", html.h1("Missing Link đâđĨ")), ) @@ -16,7 +16,7 @@ def root(): def home(): return html.div( html.h1("Home Page đ "), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index 4fd30e2..a794742 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -1,8 +1,8 @@ from typing import TypedDict from reactpy import component, html, run -from reactpy_router import link, route, simple -from reactpy_router.core import use_params + +from reactpy_router import browser_router, link, route, use_params message_data: list["MessageDataType"] = [ {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, @@ -18,14 +18,14 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", all_messages(), route("/with/{names}", messages_with()), # note the path param ), - route("*", html.h1("Missing Link đâđĨ")), + route("{404:any}", html.h1("Missing Link đâđĨ")), ) @@ -33,40 +33,32 @@ def root(): def home(): return html.div( html.h1("Home Page đ "), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + messages = [] + for msg in last_messages.values(): + msg_hyperlink = link( + {"to": f"/messages/with/{'-'.join(msg['with'])}"}, + f"Conversation with: {', '.join(msg['with'])}", + ) + msg_from = f"{'' if msg['from'] is None else 'đ´'} {msg['message']}" + messages.append(html.li({"key": msg["id"]}, html.p(msg_hyperlink), msg_from)) + return html.div( html.h1("All Messages đŦ"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - html.p( - link( - f"Conversation with: {', '.join(msg['with'])}", - to=f"/messages/with/{'-'.join(msg['with'])}", - ), - ), - f"{'' if msg['from'] is None else 'đ´'} {msg['message']}", - ) - for msg in last_messages.values() - ] - ), + html.ul(messages), ) @component def messages_with(): - names = set(use_params()["names"].split("-")) # and here we use the path param - messages = [msg for msg in message_data if set(msg["with"]) == names] + names = tuple(use_params()["names"].split("-")) # and here we use the path param + messages = [msg for msg in message_data if tuple(msg["with"]) == names] return html.div( html.h1(f"Messages with {', '.join(names)} đŦ"), html.ul( diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index 7b1193a..93a4f07 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -1,23 +1,26 @@ -from reactpy import component, html +from reactpy import component, html, run -from reactpy_router import link, route, simple, use_params +from reactpy_router import browser_router, link, route, use_params @component def user(): params = use_params() - return html.h1(f"User {params['id']} đ¤") + return html._(html.h1(f"User {params['id']} đ¤"), html.p("Nothing (yet).")) @component def root(): - return simple.router( + return browser_router( route( "/", html.div( html.h1("Home Page đ "), - link("User 123", to="/user/123"), + link({"to": "/user/123"}, "User 123"), ), ), route("/user/{id:int}", user()), ) + + +run(root) diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py deleted file mode 100644 index a8678cc..0000000 --- a/docs/examples/python/use-query.py +++ /dev/null @@ -1,23 +0,0 @@ -from reactpy import component, html - -from reactpy_router import link, route, simple, use_query - - -@component -def search(): - query = use_query() - return html.h1(f"Search Results for {query['q'][0]} đ") - - -@component -def root(): - return simple.router( - route( - "/", - html.div( - html.h1("Home Page đ "), - link("Search", to="/search?q=reactpy"), - ), - ), - route("/about", html.h1("About Page đ")), - ) diff --git a/docs/examples/python/use-search-params.py b/docs/examples/python/use-search-params.py new file mode 100644 index 0000000..faeba5e --- /dev/null +++ b/docs/examples/python/use-search-params.py @@ -0,0 +1,26 @@ +from reactpy import component, html, run + +from reactpy_router import browser_router, link, route, use_search_params + + +@component +def search(): + search_params = use_search_params() + return html._(html.h1(f"Search Results for {search_params['query'][0]} đ"), html.p("Nothing (yet).")) + + +@component +def root(): + return browser_router( + route( + "/", + html.div( + html.h1("Home Page đ "), + link({"to": "/search?query=reactpy"}, "Search"), + ), + ), + route("/search", search()), + ) + + +run(root) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d93b302..5173834 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,147 +1,137 @@ --- nav: - - Get Started: - - Add ReactPy-Router to Your Project: index.md - - Your First Routed Application: learn/simple-application.md - - Advanced Topics: - - Routers, Routes, and Links: learn/routers-routes-and-links.md - - Hooks: learn/hooks.md - - Creating a Custom Router đ§: learn/custom-router.md - - Reference: - - Core: reference/core.md - - Router: reference/router.md - - Types: reference/types.md - - About: - - Changelog: about/changelog.md - - Contributor Guide: - - Code: about/code.md - - Docs: about/docs.md - - Community: - - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions - - Discord: https://discord.gg/uNb5P4hA9X - - Reddit: https://www.reddit.com/r/ReactPy/ - - License: about/license.md + - Get Started: + - Add ReactPy-Router to Your Project: index.md + - Your First Routed Application: learn/your-first-app.md + - Advanced Topics: + - Routers, Routes, and Links: learn/routers-routes-and-links.md + - Hooks: learn/hooks.md + - Creating a Custom Router đ§: learn/custom-router.md + - Reference: + - Router Components: reference/router.md + - Components: reference/components.md + - Hooks: reference/hooks.md + - Types: reference/types.md + - About: + - Changelog: about/changelog.md + - Contributor Guide: + - Code: about/code.md + - Docs: about/docs.md + - Community: + - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions + - Discord: https://discord.gg/uNb5P4hA9X + - Reddit: https://www.reddit.com/r/ReactPy/ + - License: about/license.md theme: - name: material - custom_dir: overrides - palette: - - media: "(prefers-color-scheme: dark)" - scheme: slate - toggle: - icon: material/white-balance-sunny - name: Switch to light mode - primary: red # We use red to indicate that something is unthemed - accent: red - - media: "(prefers-color-scheme: light)" - scheme: default - toggle: - icon: material/weather-night - name: Switch to dark mode - primary: white - accent: red - features: - - navigation.instant - - navigation.tabs - - navigation.tabs.sticky - - navigation.top - - content.code.copy - - search.highlight - icon: - repo: fontawesome/brands/github - admonition: - note: fontawesome/solid/note-sticky - logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg - favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + name: material + custom_dir: overrides + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/white-balance-sunny + name: Switch to light mode + primary: red # We use red to indicate that something is unthemed + accent: red + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + primary: white + accent: red + features: + - navigation.instant + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - content.code.copy + - search.highlight + icon: + repo: fontawesome/brands/github + admonition: + note: fontawesome/solid/note-sticky + logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg markdown_extensions: - - toc: - permalink: true - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - pymdownx.tabbed: - alternate_style: true - - pymdownx.highlight: - linenums: true - - pymdownx.superfences - - pymdownx.details - - pymdownx.inlinehilite - - admonition - - attr_list - - md_in_html - - pymdownx.keys + - toc: + permalink: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + linenums: true + - pymdownx.superfences + - pymdownx.details + - pymdownx.inlinehilite + - admonition + - attr_list + - md_in_html + - pymdownx.keys plugins: - - search - - include-markdown - - git-authors - - minify: - minify_html: true - minify_js: true - minify_css: true - cache_safe: true - - git-revision-date-localized: - fallback_to_build_date: true - - spellcheck: - known_words: dictionary.txt - allow_unicode: no - ignore_code: yes - skip_files: - - "index.md" - - "reference\\core.md" - - "reference/core.md" - - "reference\\types.md" - - "reference/types.md" - - mkdocstrings: - default_handler: python - handlers: - python: - paths: ["../"] - import: - - https://reactpy.dev/docs/objects.inv - - https://installer.readthedocs.io/en/stable/objects.inv - + - search + - include-markdown + - git-authors + - minify: + minify_html: true + minify_js: true + minify_css: true + cache_safe: true + - git-revision-date-localized: + fallback_to_build_date: true + - spellcheck: + known_words: dictionary.txt + allow_unicode: no + - mkdocstrings: + default_handler: python + handlers: + python: + paths: ["../"] + import: + - https://reactpy.dev/docs/objects.inv + - https://installer.readthedocs.io/en/stable/objects.inv + options: + show_bases: false + show_root_members_full_path: true extra: - generator: false - version: - provider: mike - analytics: - provider: google - property: G-XRLQYZBG00 + generator: false + version: + provider: mike + analytics: + provider: google + property: G-XRLQYZBG00 extra_javascript: - - assets/js/main.js + - assets/js/main.js extra_css: - - assets/css/main.css - - assets/css/button.css - - assets/css/admonition.css - - assets/css/banner.css - - assets/css/sidebar.css - - assets/css/navbar.css - - assets/css/table-of-contents.css - - assets/css/code.css - - assets/css/footer.css - - assets/css/home.css + - assets/css/main.css + - assets/css/button.css + - assets/css/admonition.css + - assets/css/banner.css + - assets/css/sidebar.css + - assets/css/navbar.css + - assets/css/table-of-contents.css + - assets/css/code.css + - assets/css/footer.css + - assets/css/home.css watch: - - "../docs" - - ../README.md - - ../CHANGELOG.md - - ../LICENSE.md - - "../src" + - "../docs" + - ../README.md + - ../CHANGELOG.md + - ../LICENSE.md + - "../src" site_name: ReactPy Router site_author: Archmonger site_description: It's React-Router, but in Python. -copyright: '© -
- -Reactive Python and affiliates. - ' +copyright: '©Video description
-Video description
-Video description
-Video description
--Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.simple.router`][src.reactpy_router.simple.router]. +Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.browser_router`][reactpy_router.browser_router].
@@ -39,7 +39,7 @@ The first step is to create a basic router that will display the home page when {% include "../../examples/python/basic-routing.py" %} ``` -When navigating to [`http://127.0.0.1:8000``](http://127.0.0.1:8000) you should see `Home Page đ `. However, if you go to any other route you will instead see `Missing Link đâđĨ`. +When navigating to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) you should see `Home Page đ `. However, if you go to any other route you will instead see `Missing Link đâđĨ`. With this foundation you can start adding more routes. diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md new file mode 100644 index 0000000..f1cc570 --- /dev/null +++ b/docs/src/reference/components.md @@ -0,0 +1,4 @@ +::: reactpy_router + + options: + members: ["route", "link"] diff --git a/docs/src/reference/core.md b/docs/src/reference/core.md deleted file mode 100644 index 26cf9e5..0000000 --- a/docs/src/reference/core.md +++ /dev/null @@ -1 +0,0 @@ -::: src.reactpy_router.core diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md new file mode 100644 index 0000000..d3cfa18 --- /dev/null +++ b/docs/src/reference/hooks.md @@ -0,0 +1,4 @@ +::: reactpy_router + + options: + members: ["use_params", "use_search_params"] diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 2fcea59..5700cf5 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -1 +1,4 @@ -::: src.reactpy_router.simple +::: reactpy_router + + options: + members: ["browser_router"] diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md index 0482432..204bee7 100644 --- a/docs/src/reference/types.md +++ b/docs/src/reference/types.md @@ -1 +1 @@ -::: src.reactpy_router.types +::: reactpy_router.types diff --git a/noxfile.py b/noxfile.py index 1eebea2..6376072 100644 --- a/noxfile.py +++ b/noxfile.py @@ -33,7 +33,6 @@ def test_types(session: Session) -> None: @session(tags=["test"]) def test_style(session: Session) -> None: install_requirements_file(session, "check-style") - session.run("black", ".", "--check") session.run("ruff", "check", ".") diff --git a/pyproject.toml b/pyproject.toml index 763f3a0..d6a0110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,11 +9,9 @@ warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -[tool.ruff.isort] -known-first-party = ["src", "tests"] - [tool.ruff] -ignore = ["E501"] +lint.ignore = ["E501"] +lint.isort.known-first-party = ["src", "tests"] extend-exclude = [".venv/*", ".eggs/*", ".nox/*", "build/*"] line-length = 120 diff --git a/requirements/check-style.txt b/requirements/check-style.txt index e4f6562..af3ee57 100644 --- a/requirements/check-style.txt +++ b/requirements/check-style.txt @@ -1,2 +1 @@ -black >=23,<24 ruff diff --git a/src/js/package-lock.json b/src/js/package-lock.json index db77c4d..c98d3c6 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "reactpy-router", "dependencies": { - "htm": "^3.0.4", "react": "^17.0.1", "react-dom": "^17.0.1" }, @@ -1098,11 +1097,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3126,11 +3120,6 @@ "has-symbols": "^1.0.2" } }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/src/js/package.json b/src/js/package.json index 9baef8c..d4ac92c 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -18,16 +18,15 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { - "prettier": "^2.2.1", "eslint": "^8.38.0", "eslint-plugin-react": "^7.32.2", + "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": { - "htm": "^3.0.4", "react": "^17.0.1", "react-dom": "^17.0.1" } diff --git a/src/js/rollup.config.js b/src/js/rollup.config.js index ab1d0b1..396fd87 100644 --- a/src/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -5,7 +5,7 @@ import replace from "rollup-plugin-replace"; export default { input: "src/index.js", output: { - file: "../reactpy_router/bundle.js", + file: "../reactpy_router/static/bundle.js", format: "esm", }, plugins: [ diff --git a/src/js/src/index.js b/src/js/src/index.js index 1f43092..8ead7eb 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,8 +1,5 @@ import React from "react"; import ReactDOM from "react-dom"; -import htm from "htm"; - -const html = htm.bind(React.createElement); export function bind(node) { return { @@ -15,30 +12,69 @@ export function bind(node) { }; } -export function History({ onChange }) { - // capture changes to the browser's history +export function History({ onHistoryChange }) { + // Capture browser "history go back" action and tell the server about it + // Note: Browsers do not allow us to detect "history go forward" actions. React.useEffect(() => { + // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. const listener = () => { - onChange({ + onHistoryChange({ pathname: window.location.pathname, search: window.location.search, }); }; + + // Register the event listener window.addEventListener("popstate", listener); + + // Delete the event listener when the component is unmounted return () => window.removeEventListener("popstate", listener); }); - return null; -} -export function Link({ to, onClick, children, ...props }) { - const handleClick = (event) => { - event.preventDefault(); - window.history.pushState({}, to, new URL(to, window.location)); - onClick({ + // Tell the server about the URL during the initial page load + // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug. + // https://github.com/reactive-python/reactpy/pull/1224 + React.useEffect(() => { + onHistoryChange({ pathname: window.location.pathname, search: window.location.search, }); - }; + return () => {}; + }, []); + return null; +} - return html`${children}`; +// FIXME: The Link component is unused due to a ReactPy core rendering bug +// which causes duplicate rendering (and thus duplicate event listeners). +// https://github.com/reactive-python/reactpy/pull/1224 +export function Link({ onClick, linkClass }) { + // This component is not the actual anchor link. + // It is an event listener for the link component created by ReactPy. + React.useEffect(() => { + // Event function that will tell the server about clicks + const handleClick = (event) => { + event.preventDefault(); + let to = event.target.getAttribute("href"); + window.history.pushState({}, to, new URL(to, window.location)); + onClick({ + pathname: window.location.pathname, + search: window.location.search, + }); + }; + + // Register the event listener + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.addEventListener("click", handleClick); + } + + // Delete the event listener when the component is unmounted + return () => { + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.removeEventListener("click", handleClick); + } + }; + }); + return null; } diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index cb2fcbc..fa2781f 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,20 +1,16 @@ # the version is statically loaded by setup.py __version__ = "0.1.1" -from . import simple -from .core import create_router, link, route, router_component, use_params, use_query -from .types import Route, RouteCompiler, RouteResolver + +from .components import link, route +from .hooks import use_params, use_search_params +from .routers import browser_router, create_router __all__ = ( "create_router", "link", "route", - "route", - "Route", - "RouteCompiler", - "router_component", - "RouteResolver", - "simple", + "browser_router", "use_params", - "use_query", + "use_search_params", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py new file mode 100644 index 0000000..3008065 --- /dev/null +++ b/src/reactpy_router/components.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any +from urllib.parse import urljoin +from uuid import uuid4 + +from reactpy import component, event, html, use_connection +from reactpy.backend.types import Location +from reactpy.core.component import Component +from reactpy.core.types import VdomDict +from reactpy.web.module import export, module_from_file + +from reactpy_router.hooks import _use_route_state +from reactpy_router.types import Route + +History = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("History"), +) +"""Client-side portion of history handling""" + +Link = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("Link"), +) +"""Client-side portion of link handling""" + +link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") + + +def link(attributes: dict[str, Any], *children: Any) -> Component: + """Create a link with the given attributes and children.""" + return _link(attributes, *children) + + +@component +def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: + """A component that renders a link to the given path.""" + attributes = attributes.copy() + uuid_string = f"link-{uuid4().hex}" + class_name = f"{uuid_string}" + set_location = _use_route_state().set_location + if "className" in attributes: + class_name = " ".join([attributes.pop("className"), class_name]) + if "class_name" in attributes: # pragma: no cover + # TODO: This can be removed when ReactPy stops supporting underscores in attribute names + class_name = " ".join([attributes.pop("class_name"), class_name]) + if "href" in attributes and "to" not in attributes: + attributes["to"] = attributes.pop("href") + if "to" not in attributes: # pragma: no cover + raise ValueError("The `to` attribute is required for the `Link` component.") + to = attributes.pop("to") + + attrs = { + **attributes, + "href": to, + "className": class_name, + } + + # FIXME: This component currently works in a "dumb" way by trusting that ReactPy's script tag \ + # properly sets the location due to bugs in ReactPy rendering. + # https://github.com/reactive-python/reactpy/pull/1224 + current_path = use_connection().location.pathname + + @event(prevent_default=True) + def on_click(_event: dict[str, Any]) -> None: + pathname, search = to.split("?", 1) if "?" in to else (to, "") + if search: + search = f"?{search}" + + # Resolve relative paths that match `../foo` + if pathname.startswith("../"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `foo` + if not pathname.startswith("/"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `/foo/../bar` + while "/../" in pathname: + part_1, part_2 = pathname.split("/../", 1) + pathname = urljoin(f"{part_1}/", f"../{part_2}") + + # Resolve relative paths that match `foo/./bar` + pathname = pathname.replace("/./", "/") + + set_location(Location(pathname, search)) + + attrs["onClick"] = on_click + + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) + + # def on_click(_event: dict[str, Any]) -> None: + # set_location(Location(**_event)) + # return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string})) + + +def route(path: str, element: Any | None, *routes: Route) -> Route: + """Create a route with the given path, element, and child routes.""" + return Route(path, element, routes) diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py new file mode 100644 index 0000000..5fe1b5e --- /dev/null +++ b/src/reactpy_router/converters.py @@ -0,0 +1,37 @@ +import uuid + +from reactpy_router.types import ConversionInfo + +__all__ = ["CONVERTERS"] + +CONVERTERS: dict[str, ConversionInfo] = { + "int": { + "regex": r"\d+", + "func": int, + }, + "str": { + "regex": r"[^/]+", + "func": str, + }, + "uuid": { + "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + "func": uuid.UUID, + }, + "slug": { + "regex": r"[-a-zA-Z0-9_]+", + "func": str, + }, + "path": { + "regex": r".+", + "func": str, + }, + "float": { + "regex": r"\d+(\.\d+)?", + "func": float, + }, + "any": { + "regex": r".*", + "func": str, + }, +} +"""The conversion types supported by the default Resolver.""" diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py deleted file mode 100644 index 490a78c..0000000 --- a/src/reactpy_router/core.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Core functionality for the reactpy-router package.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Any, Callable, Iterator, Sequence, TypeVar -from urllib.parse import parse_qs - -from reactpy import ( - component, - create_context, - html, - use_context, - use_location, - use_memo, - use_state, -) -from reactpy.backend.hooks import ConnectionContext, use_connection -from reactpy.backend.types import Connection, Location -from reactpy.core.types import VdomChild, VdomDict -from reactpy.types import ComponentType, Context -from reactpy.web.module import export, module_from_file - -from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver - -R = TypeVar("R", bound=Route) - - -def route(path: str, element: Any | None, *routes: Route) -> Route: - """Create a route with the given path, element, and child routes""" - return Route(path, element, routes) - - -def create_router(compiler: RouteCompiler[R]) -> Router[R]: - """A decorator that turns a route compiler into a router""" - - def wrapper(*routes: R) -> ComponentType: - return router_component(*routes, compiler=compiler) - - return wrapper - - -@component -def router_component( - *routes: R, - compiler: RouteCompiler[R], -) -> VdomDict | None: - """A component that renders the first matching route using the given compiler""" - - old_conn = use_connection() - location, set_location = use_state(old_conn.location) - - resolvers = use_memo( - lambda: tuple(map(compiler, _iter_routes(routes))), - dependencies=(compiler, hash(routes)), - ) - - match = use_memo(lambda: _match_route(resolvers, location)) - - if match is not None: - element, params = match - return html._( - ConnectionContext( - _route_state_context(element, value=_RouteState(set_location, params)), - value=Connection(old_conn.scope, location, old_conn.carrier), - ), - _history({"on_change": lambda event: set_location(Location(**event))}), - ) - - return None - - -@component -def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: - """A component that renders a link to the given path""" - set_location = _use_route_state().set_location - attrs = { - **attributes, - "to": to, - "onClick": lambda event: set_location(Location(**event)), - } - return _link(attrs, *children) - - -def use_params() -> dict[str, Any]: - """Get parameters from the currently matching route pattern""" - return _use_route_state().params - - -def use_query( - keep_blank_values: bool = False, - strict_parsing: bool = False, - errors: str = "replace", - max_num_fields: int | None = None, - separator: str = "&", -) -> dict[str, list[str]]: - """See :func:`urllib.parse.parse_qs` for parameter info.""" - return parse_qs( - use_location().search[1:], - keep_blank_values=keep_blank_values, - strict_parsing=strict_parsing, - errors=errors, - max_num_fields=max_num_fields, - separator=separator, - ) - - -def _iter_routes(routes: Sequence[R]) -> Iterator[R]: - for parent in routes: - for child in _iter_routes(parent.routes): - yield replace(child, path=parent.path + child.path) # type: ignore[misc] - yield parent - - -def _match_route( - compiled_routes: Sequence[RouteResolver], location: Location -) -> tuple[Any, dict[str, Any]] | None: - for resolver in compiled_routes: - match = resolver.resolve(location.pathname) - if match is not None: - return match - return None - - -_link, _history = export( - module_from_file("reactpy-router", file=Path(__file__).parent / "bundle.js"), - ("Link", "History"), -) - - -@dataclass -class _RouteState: - set_location: Callable[[Location], None] - params: dict[str, Any] - - -def _use_route_state() -> _RouteState: - route_state = use_context(_route_state_context) - assert route_state is not None - return route_state - - -_route_state_context: Context[_RouteState | None] = create_context(None) diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py new file mode 100644 index 0000000..3831acf --- /dev/null +++ b/src/reactpy_router/hooks.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable +from urllib.parse import parse_qs + +from reactpy import create_context, use_context, use_location +from reactpy.backend.types import Location +from reactpy.types import Context + + +@dataclass +class _RouteState: + set_location: Callable[[Location], None] + params: dict[str, Any] + + +def _use_route_state() -> _RouteState: + route_state = use_context(_route_state_context) + if route_state is None: # pragma: no cover + raise RuntimeError( + "ReactPy-Router was unable to find a route context. Are you " + "sure this hook/component is being called within a router?" + ) + + return route_state + + +_route_state_context: Context[_RouteState | None] = create_context(None) + + +def use_params() -> dict[str, Any]: + """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \ + from the current URL that were matched by the `Route`. Child routes inherit all parameters \ + from their parent routes. + + For example, if you have a `URL_PARAM` defined in the route `/example/