├── .github ├── dependabot.yml └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── LICENCE ├── README.md ├── docs ├── assets │ ├── ludic.png │ └── quick-demo.gif ├── index.md └── requirements.txt ├── examples ├── README.md ├── __init__.py ├── bulk_update.py ├── click_to_edit.py ├── click_to_load.py ├── delete_row.py ├── edit_row.py ├── fastapi_example.py ├── infinite_scroll.py └── lazy_loading.py ├── ludic ├── __init__.py ├── attrs.py ├── base.py ├── catalog │ ├── __init__.py │ ├── buttons.py │ ├── forms.py │ ├── headers.py │ ├── icons.py │ ├── items.py │ ├── layouts.py │ ├── lists.py │ ├── loaders.py │ ├── messages.py │ ├── navigation.py │ ├── pages.py │ ├── quotes.py │ ├── tables.py │ ├── typography.py │ └── utils.py ├── components.py ├── contrib │ ├── __init__.py │ ├── django │ │ ├── __init__.py │ │ ├── middlewares.py │ │ └── responses.py │ └── fastapi │ │ ├── __init__.py │ │ └── routing.py ├── elements.py ├── format.py ├── html.py ├── mypy_plugin.py ├── py.typed ├── styles │ ├── __init__.py │ ├── collect.py │ ├── highlight.py │ ├── themes.py │ ├── types.py │ └── utils.py ├── types.py ├── utils.py └── web │ ├── __init__.py │ ├── app.py │ ├── datastructures.py │ ├── endpoints.py │ ├── exceptions.py │ ├── parsers.py │ ├── requests.py │ ├── responses.py │ └── routing.py ├── mkdocs.yaml ├── pyproject.toml ├── tests ├── __init__.py ├── contrib │ ├── __init__.py │ ├── test_django.py │ └── test_fastapi.py ├── styles │ ├── __init__.py │ ├── test_styles.py │ └── test_themes.py ├── test_catalog.py ├── test_components.py ├── test_elements.py ├── test_examples.py ├── test_exceptions.py ├── test_formatting.py ├── test_types.py └── web │ ├── __init__.py │ ├── test_datastructures.py │ ├── test_parsers.py │ ├── test_requests.py │ └── test_routing.py └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "[0-9].[0-9]+.[0-9]+" 8 | - "[0-9].[0-9]+.[0-9]+-pre[0-9]?" 9 | 10 | jobs: 11 | publish: 12 | name: Release to GitHub and PyPI 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | cache: pip 25 | cache-dependency-path: "**/pyproject.toml" 26 | 27 | - run: pip install uv 28 | 29 | - name: Build Package 30 | run: uv build 31 | 32 | - name: Publish Release Notes 33 | id: release 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | files: | 37 | ./dist/*.whl 38 | ./dist/*.tar.gz 39 | prerelease: ${{ contains(github.ref, '-pre') }} 40 | generate_release_notes: ${{ !contains(github.ref, '-pre') }} 41 | 42 | - name: Publish Distribution Package to PyPI 43 | uses: pypa/gh-action-pypi-publish@release/v1 44 | with: 45 | password: ${{ secrets.PYPI_API_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | lint: 12 | name: Run pre-commit hooks 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 3.12 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.12' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install pre-commit 26 | 27 | - name: Lint with pre-commit 28 | run: | 29 | pre-commit run -a --show-diff-on-failure 30 | 31 | test: 32 | name: Run Unit Tests 33 | needs: [lint] 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | python-version: [3.12, 3.13] 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | 49 | - name: Install dependencies 50 | run: | 51 | python -m pip install --upgrade pip setuptools setuptools_scm 52 | python -m pip install .[full,test,django] 53 | 54 | - name: Test with pytest 55 | run: | 56 | pytest --cov --cov-report xml:coverage.xml 57 | 58 | - name: Upload coverage to Codecov 59 | uses: codecov/codecov-action@v5.4.2 60 | with: 61 | token: ${{ secrets.CODECOV_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 86 | __pypackages__/ 87 | 88 | # Celery stuff 89 | celerybeat-schedule 90 | celerybeat.pid 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | # pytype static type analyzer 123 | .pytype/ 124 | 125 | # Cython debug symbols 126 | cython_debug/ 127 | 128 | # IDEs 129 | .vscode 130 | .idea 131 | 132 | # Version file 133 | **/_version.py 134 | .python-version 135 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | default_install_hook_types: 5 | - pre-commit 6 | - commit-msg 7 | 8 | default_stages: 9 | - pre-commit 10 | 11 | repos: 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v5.0.0 14 | hooks: 15 | - id: trailing-whitespace 16 | - id: end-of-file-fixer 17 | - id: check-yaml 18 | - id: check-toml 19 | - id: check-added-large-files 20 | args: ["--maxkb=100000"] 21 | - id: detect-private-key 22 | 23 | - repo: https://github.com/astral-sh/ruff-pre-commit 24 | rev: v0.8.1 25 | hooks: 26 | - id: ruff-format 27 | - id: ruff 28 | args: ["--fix", "--exit-non-zero-on-fix", "--config", "pyproject.toml"] 29 | 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v1.13.0 32 | hooks: 33 | - id: mypy 34 | args: ["--ignore-missing-imports"] 35 | additional_dependencies: 36 | - types-cachetools 37 | - typeguard 38 | - django 39 | - fastapi 40 | 41 | - repo: https://github.com/compilerla/conventional-pre-commit 42 | rev: v3.6.0 43 | hooks: 44 | - id: conventional-pre-commit 45 | stages: [commit-msg] 46 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.12" 7 | 8 | mkdocs: 9 | configuration: mkdocs.yaml 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Any contributions to the framework are warmly welcome! Your help will make it a better resource for the community. If you're ready to contribute, here's how to get started. 4 | 5 | ### Set Up Pre-Commit Hooks 6 | 7 | Before submitting code, set up pre-commit hooks to ensure code quality and consistency. Instructions on how to do this should be included in your project's setup documentation. 8 | 9 | ```bash 10 | python -m pip install pre-commit 11 | pre-commit install --install-hooks 12 | ``` 13 | 14 | ### Code Changes 15 | 16 | If you're adding new features or fixing bugs, please include tests that cover your changes. Run `pytest` to execute the test suite. 17 | 18 | You need to install test dependencies to be able to run `pytest`: 19 | 20 | ```bash 21 | python -m pip install -e ".[full,test]" 22 | ``` 23 | 24 | You can also use `hatch` which automatically creates virtual environment with all the optional dependencies: 25 | 26 | ```bash 27 | hatch shell 28 | ``` 29 | 30 | ### Documentation Updates 31 | 32 | We use MkDocs for documentation. To make changes to the documentation: 33 | 34 | - Install MkDocs if you don't already have it with `pip install mkdocs-material`. 35 | - Make your edits to the Markdown files within the documentation directory. 36 | - Run `mkdocs serve` to preview your changes. 37 | 38 | ### Create a Pull Request 39 | 40 | - Fork the repository and create a new branch for your feature or bug fix. 41 | - Push your changes to your branch. 42 | - Create a pull request, providing a brief description of your changes and why they are important if needed. 43 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pavel Dedík 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Ludic Logo 3 |

4 | 5 | [![test](https://github.com/getludic/ludic/actions/workflows/test.yaml/badge.svg)](https://github.com/getludic/ludic/actions) [![codecov](https://codecov.io/gh/getludic/ludic/graph/badge.svg?token=BBDNJWHMGX)](https://codecov.io/gh/getludic/ludic) [![Python 3.12 and 3.13](https://img.shields.io/badge/Python-3.12%20%7C%203.13-blue)](https://www.python.org/downloads/release/python-3130/) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) [![Discord Server](https://img.shields.io/badge/discord-ludic-black)](https://discord.gg/7nK4zAXAYC) 6 | 7 | **Documentation**: https://getludic.dev/docs/ 8 | 9 | --- 10 | 11 | *"I've just composed my first `PageLayout` component and I have no words!"* 12 | 13 | – Igor Davydenko 14 | 15 | --- 16 | 17 | Ludic is a lightweight framework for building HTML pages with a component approach similar to [React](https://react.dev/). It is built to be used together with [htmx.org](https://htmx.org/) so that developers don't need to write almost any JavaScript to create dynamic web services. Its potential can be leveraged together with its web framework which is a wrapper around the powerful [Starlette](https://www.starlette.io/) framework. It is built with the latest Python 3.12 features heavily incorporating typing. 18 | 19 | ## Features 20 | 21 | - Seamless **</> htmx** integration for rapid web development in **pure Python** 22 | - **Type-Guided components** utilizing Python's typing system 23 | - Uses the power of **Starlette** and **Async** for high-performance web development 24 | - Build HTML with the ease and power of Python **f-strings** 25 | - Add CSS styling to your components with **Themes** 26 | - Create simple, responsive layouts adopted from the **Every Layout Book** 27 | 28 | ## Comparison 29 | 30 | Here is a table comparing Ludic to other similar tools: 31 | 32 | | Feature | Ludic | FastUI | Reflex | 33 | |-----------------------------|-------------|-------------|-------------| 34 | | HTML rendering | Server Side | Client Side | Client Side | 35 | | Uses a template engine | No | No | No | 36 | | UI interactivity | [ htmx](https://htmx.org)* | [React](https://react.dev/) | [React](https://react.dev/) | 37 | | Backend framework | [Starlette](https://www.starlette.io), [FastAPI](https://fastapi.tiangolo.com/), [Django](https://www.djangoproject.com/)* | [FastAPI](https://fastapi.tiangolo.com) | [FastAPI](https://fastapi.tiangolo.com) | 38 | | Client-Server Communication | [HTML + REST](https://htmx.org/essays/how-did-rest-come-to-mean-the-opposite-of-rest/) | [JSON + REST](https://github.com/pydantic/FastUI?tab=readme-ov-file#the-principle-long-version) | [WebSockets](https://reflex.dev/blog/2024-03-21-reflex-architecture/) | 39 | 40 | (*) HTMX as well as Starlette, FastAPI and Django are optional dependencies for Ludic, it does not enforce any frontend or backend frameworks. At it's core, Ludic only generates HTML and allows registering CSS. 41 | 42 | ## Motivation 43 | 44 | This framework allows HTML generation in Python while utilizing Python's typing system. Our goal is to enable the creation of dynamic web applications with reusable components, all while offering a greater level of type safety than raw HTML. 45 | 46 | **Key Ideas:** 47 | 48 | - **Type-Guided HTML**: Catch potential HTML structural errors at development time thanks to type hints. The framework enforces stricter rules than standard HTML, promoting well-structured and maintainable code. 49 | - **Composable Components**: Define reusable, dynamic HTML components in pure Python. This aligns with modern web development practices, emphasizing modularity. 50 | 51 | ### Type-Guided HTML 52 | 53 | Here is an example of how Python's type system can be leveraged to enforce HTML structure: 54 | 55 | ```python 56 | br("Hello, World!") # type error (
can't have children) 57 | br() # ok 58 | 59 | html(body(...)) # type error (first child must be a ) 60 | html(head(...), body(...)) # ok 61 | 62 | div("Test", href="test") # type error (unknown attribute) 63 | a("Test", href="...") # ok 64 | ``` 65 | 66 | ### Composable Components 67 | 68 | Instead of using only basic HTML elements, it is possible to create modular components with the support of Python's type system. Let's take a look at an example: 69 | 70 | ```python 71 | Table( 72 | TableHead("Id", "Name"), 73 | TableRow("1", "John"), 74 | TableRow("2", "Jane"), 75 | TableRow("3", "Bob"), 76 | ) 77 | ``` 78 | 79 | This structure can be type-checked thanks to Python's rich type system. Additionally, this `Table` component could have **dynamic properties** like sorting or filtering. 80 | 81 | ## Requirements 82 | 83 | Python 3.12+ 84 | 85 | ## Installation 86 | 87 | ``` 88 | pip install "ludic[full]" 89 | ``` 90 | 91 | You can also use a basic cookiecutter template to get quickly started, using [UV](https://docs.astral.sh/uv/), you need to run only one command: 92 | 93 | ``` 94 | uvx cookiecutter gh:getludic/template 95 | ``` 96 | 97 | ## Full Example 98 | 99 | **components.py**: 100 | 101 | ```python 102 | from typing import override 103 | 104 | from ludic import Attrs, Component 105 | from ludic.html import a 106 | 107 | class LinkAttrs(Attrs): 108 | to: str 109 | 110 | class Link(Component[str, LinkAttrs]): 111 | classes = ["link"] 112 | 113 | @override 114 | def render(self) -> a: 115 | return a( 116 | *self.children, 117 | href=self.attrs["to"], 118 | style={"color": self.theme.colors.primary}, 119 | ) 120 | ``` 121 | 122 | Now you can use it like this: 123 | 124 | ```python 125 | link = Link("Hello, World!", to="/home") 126 | ``` 127 | 128 | **web.py**: 129 | 130 | ```python 131 | from ludic.web import LudicApp 132 | from ludic.html import b, p 133 | 134 | from .components import Link 135 | 136 | app = LudicApp() 137 | 138 | @app.get("/") 139 | async def homepage() -> p: 140 | return p(f"Hello {b("Stranger")}! Click {Link("here", to="https://example.com")}!") 141 | ``` 142 | 143 | To run the application: 144 | 145 | ```python 146 | uvicorn web:app 147 | ``` 148 | 149 | ### Integrations 150 | 151 | Here is a list of integrations and a link to the guide on how to get started: 152 | 153 | * [Starlette](https://getludic.dev/docs/web-framework) 154 | * [FastAPI](https://getludic.dev/docs/integrations#fastapi) 155 | * [Django](https://getludic.dev/docs/integrations#django) 156 | 157 | ### More Examples 158 | 159 | For more complex usage incorporating all capabilities of the framework: 160 | 161 | - visit the examples on [getludic.dev](https://getludic.dev/examples) 162 | - check the folder with examples [on GitHub](https://github.com/getludic/ludic/tree/master/examples/) 163 | - check the source code of the [Ludic Catalog](https://github.com/getludic/ludic/tree/main/ludic/catalog) 164 | - inspect the [source of getludic.dev](https://github.com/getludic/web/blob/main/web/endpoints/docs/htmx.py) which is written with Ludic 165 | - check [this small repository](https://github.com/getludic/ludic-slides/blob/main/ludic_slides/components.py) rendering slides in the browser 166 | 167 | ## Contributing 168 | 169 | Any contributions to the framework are warmly welcome! Your help will make it a better resource for the community. If you're ready to contribute, read the [contribution guide](https://github.com/getludic/ludic/tree/master/CONTRIBUTING.md). 170 | 171 | * [GitHub Issues](https://github.com/getludic/ludic/issues) - If you encounter a bug, please report it here. 172 | * [GitHub Discussions](https://github.com/getludic/ludic/discussions) - To request a new feature, this is the best place to initiate the discussion. 173 | * [Discord](https://discord.gg/7nK4zAXAYC) - Join our Discord server for support, sharing ideas, and receiving assistance. 174 | -------------------------------------------------------------------------------- /docs/assets/ludic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getludic/ludic/6f972ea35cd55fbce5a03a88077410114bf77e92/docs/assets/ludic.png -------------------------------------------------------------------------------- /docs/assets/quick-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getludic/ludic/6f972ea35cd55fbce5a03a88077410114bf77e92/docs/assets/quick-demo.gif -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | ludic 3 |

4 | 5 | [![test](https://github.com/getludic/ludic/actions/workflows/test.yaml/badge.svg)](https://github.com/getludic/ludic/actions) [![codecov](https://codecov.io/gh/getludic/ludic/graph/badge.svg?token=BBDNJWHMGX)](https://codecov.io/gh/getludic/ludic) [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-312/) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) [![Discord Server](https://img.shields.io/badge/discord-ludic-black)](https://discord.gg/4Y5fSQUS) 6 | 7 | 8 | **Documentation moved to:** 9 | 10 | * [https://getludic.dev](https://getludic.dev/docs/) 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==9.5.13 2 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | All the examples currently correspond reimplementation of samples from [htmx.org](https://htmx.org/examples/lazy-load/). 4 | 5 | ## Documentation 6 | 7 | The documentation for some of the examples can be found here: 8 | 9 | * [https://getludic.dev/examples/](https://getludic.dev/examples/) 10 | 11 | ## Running 12 | 13 | Make sure you are using Python 3.12+ and have Ludic and Uvicorn installed: 14 | 15 | ```bash 16 | pip install "ludic[full]" 17 | pip install uvicorn 18 | ``` 19 | 20 | Now you can run any example with the following command (you must run the command from the root of the repository): 21 | 22 | ```bash 23 | uvicorn examples.:app --reload 24 | ``` 25 | 26 | Visit `http://127.0.0.1:8000` in your browser to see the example. 27 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | from typing import Any, Self, override 4 | 5 | from ludic.attrs import NoAttrs 6 | from ludic.catalog.layouts import Center, Stack 7 | from ludic.catalog.pages import Body, Head, HtmlPage 8 | from ludic.components import Component 9 | from ludic.html import meta 10 | from ludic.styles import set_default_theme, themes, types 11 | from ludic.types import AnyChildren 12 | 13 | set_default_theme(themes.LightTheme(measure=types.Size(90, "ch"))) 14 | 15 | 16 | @dataclass 17 | class Model: 18 | def to_dict(self) -> dict[str, Any]: 19 | return asdict(self) 20 | 21 | 22 | @dataclass 23 | class ContactData(Model): 24 | id: str 25 | first_name: str 26 | last_name: str 27 | email: str 28 | 29 | 30 | @dataclass 31 | class PersonData(Model): 32 | id: str 33 | name: str 34 | email: str 35 | active: bool = True 36 | 37 | 38 | @dataclass 39 | class CarData(Model): 40 | name: str 41 | models: list[str] 42 | 43 | 44 | @dataclass 45 | class DB(Model): 46 | contacts: dict[str, ContactData] 47 | people: dict[str, PersonData] 48 | cars: dict[str, CarData] 49 | 50 | def find_all_cars_names(self) -> list[str]: 51 | return [car.name for car in self.cars.values()] 52 | 53 | def find_first_car(self) -> CarData: 54 | return self.cars["1"] 55 | 56 | def find_car_by_name(self, name: str | None) -> CarData | None: 57 | for car in self.cars.values(): 58 | if car.name.lower() == name: 59 | return car 60 | return None 61 | 62 | def to_json(self) -> str: 63 | return json.dumps(self.to_dict()) 64 | 65 | @classmethod 66 | def from_dict(cls, data: dict[str, Any]) -> Self: 67 | return cls( 68 | contacts={k: ContactData(**v) for k, v in data.get("contacts", {}).items()}, 69 | people={k: PersonData(**v) for k, v in data.get("people", {}).items()}, 70 | cars={k: CarData(**v) for k, v in data.get("cars", {}).items()}, 71 | ) 72 | 73 | 74 | def init_contacts() -> dict[str, ContactData]: 75 | return { 76 | "1": ContactData( 77 | id="1", 78 | first_name="John", 79 | last_name="Doe", 80 | email="qN6Z8@example.com", 81 | ) 82 | } 83 | 84 | 85 | def init_people() -> dict[str, PersonData]: 86 | return { 87 | "1": PersonData( 88 | id="1", 89 | name="Joe Smith", 90 | email="joe@smith.org", 91 | active=True, 92 | ), 93 | "2": PersonData( 94 | id="2", 95 | name="Angie MacDowell", 96 | email="angie@macdowell.org", 97 | active=True, 98 | ), 99 | "3": PersonData( 100 | id="3", 101 | name="Fuqua Tarkenton", 102 | email="fuqua@tarkenton.org", 103 | active=True, 104 | ), 105 | "4": PersonData( 106 | id="4", 107 | name="Kim Yee", 108 | email="kim@yee.org", 109 | active=False, 110 | ), 111 | } 112 | 113 | 114 | def init_cars() -> dict[str, CarData]: 115 | return { 116 | "1": CarData( 117 | name="Audi", 118 | models=["A1", "A4", "A6"], 119 | ), 120 | "2": CarData( 121 | name="Toyota", 122 | models=["Landcruiser", "Tacoma", "Yaris"], 123 | ), 124 | "3": CarData( 125 | name="BMW", 126 | models=["325i", "325ix", "X5"], 127 | ), 128 | } 129 | 130 | 131 | def init_db() -> DB: 132 | return DB(contacts=init_contacts(), people=init_people(), cars=init_cars()) 133 | 134 | 135 | class Page(Component[AnyChildren, NoAttrs]): 136 | @override 137 | def render(self) -> HtmlPage: 138 | return HtmlPage( 139 | Head( 140 | meta(charset="utf-8"), 141 | meta(name="viewport", content="width=device-width, initial-scale=1.0"), 142 | title="HTMX Examples", 143 | ), 144 | Body( 145 | Center( 146 | Stack(*self.children, id="content"), 147 | style={"padding": self.theme.sizes.xxl}, 148 | ), 149 | htmx_version="2.0.2", 150 | ), 151 | ) 152 | -------------------------------------------------------------------------------- /examples/bulk_update.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Self, override 2 | 3 | from examples import Page, init_db 4 | 5 | from ludic.attrs import Attrs 6 | from ludic.catalog.buttons import ButtonPrimary 7 | from ludic.catalog.forms import FieldMeta, Form 8 | from ludic.catalog.headers import H1, H2 9 | from ludic.catalog.layouts import Cluster 10 | from ludic.catalog.quotes import Quote 11 | from ludic.catalog.tables import ColumnMeta, Table, create_rows 12 | from ludic.components import Inline 13 | from ludic.html import span, style 14 | from ludic.web import Endpoint, LudicApp 15 | from ludic.web.parsers import ListParser 16 | 17 | db = init_db() 18 | app = LudicApp(debug=True) 19 | 20 | 21 | class PersonAttrs(Attrs, total=False): 22 | id: Annotated[str, ColumnMeta(identifier=True)] 23 | name: Annotated[str, ColumnMeta()] 24 | email: Annotated[str, ColumnMeta()] 25 | active: Annotated[ 26 | bool, 27 | ColumnMeta(kind=FieldMeta(kind="checkbox", label=None)), 28 | ] 29 | 30 | 31 | class PeopleAttrs(Attrs): 32 | people: list[PersonAttrs] 33 | 34 | 35 | class Toast(Inline): 36 | id: str = "toast" 37 | target: str = f"#{id}" 38 | styles = style.use( 39 | lambda theme: { 40 | Toast.target: { 41 | "background": theme.colors.success, 42 | "padding": f"{theme.sizes.xxxxs} {theme.sizes.xxxs}", 43 | "font-size": theme.fonts.size * 0.9, 44 | "border-radius": "3px", 45 | "opacity": "0", 46 | "transition": "opacity 3s ease-out", 47 | }, 48 | f"{Toast.target}.htmx-settling": { 49 | "opacity": "100", 50 | }, 51 | } 52 | ) 53 | 54 | @override 55 | def render(self) -> span: 56 | return span(*self.children, id=self.id) 57 | 58 | 59 | @app.get("/") 60 | async def index() -> Page: 61 | return Page( 62 | H1("Bulk Update"), 63 | Quote( 64 | "This demo shows how to implement a common pattern where rows are " 65 | "selected and then bulk updated.", 66 | source_url="https://htmx.org/examples/bulk-update/", 67 | ), 68 | H2("Demo"), 69 | await PeopleTable.get(), 70 | ) 71 | 72 | 73 | @app.endpoint("/people/") 74 | class PeopleTable(Endpoint[PeopleAttrs]): 75 | @classmethod 76 | async def post(cls, data: ListParser[PersonAttrs]) -> Toast: 77 | items = {row["id"]: row for row in data.validate()} 78 | activations = {True: 0, False: 0} 79 | 80 | for person in db.people.values(): 81 | active = items.get(person.id, {}).get("active", False) 82 | if person.active != active: 83 | person.active = active 84 | activations[active] += 1 85 | 86 | return Toast(f"Activated {activations[True]}, deactivated {activations[False]}") 87 | 88 | @classmethod 89 | async def get(cls) -> Self: 90 | return cls(people=[person.to_dict() for person in db.people.values()]) 91 | 92 | @override 93 | def render(self) -> Form: 94 | return Form( 95 | Table(*create_rows(self.attrs["people"], spec=PersonAttrs)), 96 | Cluster( 97 | ButtonPrimary("Bulk Update", type="submit"), 98 | Toast(), 99 | ), 100 | hx_post=self.url_for(PeopleTable), 101 | hx_target=Toast.target, 102 | hx_swap="outerHTML settle:3s", 103 | ) 104 | -------------------------------------------------------------------------------- /examples/click_to_edit.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, NotRequired, Self, override 2 | 3 | from examples import Page, init_db 4 | 5 | from ludic.attrs import Attrs 6 | from ludic.catalog.buttons import Button, ButtonDanger, ButtonPrimary 7 | from ludic.catalog.forms import FieldMeta, Form, create_fields 8 | from ludic.catalog.headers import H1, H2 9 | from ludic.catalog.items import Pairs 10 | from ludic.catalog.layouts import Box, Cluster, Stack 11 | from ludic.catalog.quotes import Quote 12 | from ludic.web import Endpoint, LudicApp 13 | from ludic.web.exceptions import NotFoundError 14 | from ludic.web.parsers import Parser, ValidationError 15 | 16 | db = init_db() 17 | app = LudicApp(debug=True) 18 | 19 | 20 | def email_validator(email: str) -> str: 21 | if len(email.split("@")) != 2: 22 | raise ValidationError("Invalid email") 23 | return email 24 | 25 | 26 | class ContactAttrs(Attrs): 27 | id: NotRequired[str] 28 | first_name: Annotated[str, FieldMeta(label="First Name")] 29 | last_name: Annotated[str, FieldMeta(label="Last Name")] 30 | email: Annotated[ 31 | str, FieldMeta(label="Email", type="email", parser=email_validator) 32 | ] 33 | 34 | 35 | @app.get("/") 36 | async def index() -> Page: 37 | return Page( 38 | H1("Click To Edit"), 39 | Quote( 40 | "The click to edit pattern provides a way to offer inline editing " 41 | "of all or part of a record without a page refresh.", 42 | source_url="https://htmx.org/examples/click-to-edit/", 43 | ), 44 | H2("Demo"), 45 | Box(*[Contact(**contact.to_dict()) for contact in db.contacts.values()]), 46 | ) 47 | 48 | 49 | @app.endpoint("/contacts/{id}") 50 | class Contact(Endpoint[ContactAttrs]): 51 | @classmethod 52 | async def get(cls, id: str) -> Self: 53 | contact = db.contacts.get(id) 54 | 55 | if contact is None: 56 | raise NotFoundError("Contact not found") 57 | 58 | return cls(**contact.to_dict()) 59 | 60 | @classmethod 61 | async def put(cls, id: str, attrs: Parser[ContactAttrs]) -> Self: 62 | contact = db.contacts.get(id) 63 | 64 | if contact is None: 65 | raise NotFoundError("Contact not found") 66 | 67 | for key, value in attrs.validate().items(): 68 | setattr(contact, key, value) 69 | 70 | return cls(**contact.to_dict()) 71 | 72 | @override 73 | def render(self) -> Stack: 74 | return Stack( 75 | Pairs(items=self.attrs.items()), # type: ignore 76 | Cluster( 77 | Button( 78 | "Click To Edit", 79 | hx_get=self.url_for(ContactForm), 80 | ), 81 | ), 82 | hx_target="this", 83 | hx_swap="outerHTML", 84 | ) 85 | 86 | 87 | @app.endpoint("/contacts/{id}/form/") 88 | class ContactForm(Endpoint[ContactAttrs]): 89 | @classmethod 90 | async def get(cls, id: str) -> Self: 91 | contact = db.contacts.get(id) 92 | 93 | if contact is None: 94 | raise NotFoundError("Contact not found") 95 | 96 | return cls(**contact.to_dict()) 97 | 98 | @override 99 | def render(self) -> Form: 100 | return Form( 101 | *create_fields(self.attrs, spec=ContactAttrs), 102 | Cluster( 103 | ButtonPrimary("Submit"), 104 | ButtonDanger("Cancel", hx_get=self.url_for(Contact)), 105 | ), 106 | hx_put=self.url_for(Contact), 107 | hx_target="this", 108 | hx_swap="outerHTML", 109 | ) 110 | -------------------------------------------------------------------------------- /examples/click_to_load.py: -------------------------------------------------------------------------------- 1 | from typing import Self, override 2 | 3 | from examples import Page 4 | 5 | from ludic.attrs import Attrs 6 | from ludic.catalog.buttons import ButtonPrimary 7 | from ludic.catalog.headers import H1, H2 8 | from ludic.catalog.quotes import Quote 9 | from ludic.catalog.tables import Table, TableHead, TableRow 10 | from ludic.components import Component, ComponentStrict 11 | from ludic.elements import Blank 12 | from ludic.html import td 13 | from ludic.types import URLType 14 | from ludic.web import Endpoint, LudicApp 15 | from ludic.web.datastructures import QueryParams 16 | 17 | app = LudicApp(debug=True) 18 | 19 | 20 | class ContactAttrs(Attrs): 21 | id: str 22 | name: str 23 | email: str 24 | 25 | 26 | class ContactsSliceAttrs(Attrs): 27 | page: int 28 | contacts: list[ContactAttrs] 29 | 30 | 31 | class LoadMoreAttrs(Attrs): 32 | url: URLType 33 | 34 | 35 | def load_contacts(page: int) -> list[ContactAttrs]: 36 | return [ 37 | ContactAttrs( 38 | id=str(page * 10 + idx), 39 | email=f"void{page * 10 + idx}@null.org", 40 | name="Agent Smith", 41 | ) 42 | for idx in range(10) 43 | ] 44 | 45 | 46 | class LoadMoreButton(ComponentStrict[LoadMoreAttrs]): 47 | target: str = "replace-me" 48 | 49 | @override 50 | def render(self) -> ButtonPrimary: 51 | return ButtonPrimary( 52 | "Load More Agents...", 53 | hx_get=self.attrs["url"], 54 | hx_target=f"#{self.target}", 55 | hx_swap="outerHTML", 56 | ) 57 | 58 | 59 | @app.get("/") 60 | async def index() -> Page: 61 | return Page( 62 | H1("Click To Load"), 63 | Quote( 64 | "This example shows how to implement click-to-load the next page in " 65 | "a table of data.", 66 | source_url="https://htmx.org/examples/click-to-load/", 67 | ), 68 | H2("Demo"), 69 | ContactsTable(await ContactsSlice.get(QueryParams(page=1))), 70 | ) 71 | 72 | 73 | @app.endpoint("/contacts/") 74 | class ContactsSlice(Endpoint[ContactsSliceAttrs]): 75 | @classmethod 76 | async def get(cls, params: QueryParams) -> Self: 77 | page = int(params.get("page", 1)) 78 | return cls(page=page, contacts=load_contacts(page)) 79 | 80 | @override 81 | def render(self) -> Blank[TableRow]: 82 | next_page = self.attrs["page"] + 1 83 | return Blank( 84 | *( 85 | TableRow(contact["id"], contact["name"], contact["email"]) 86 | for contact in self.attrs["contacts"] 87 | ), 88 | TableRow( 89 | td( 90 | LoadMoreButton( 91 | url=self.url_for(ContactsSlice).include_query_params( 92 | page=next_page 93 | ), 94 | ), 95 | colspan=3, 96 | ), 97 | id=LoadMoreButton.target, 98 | ), 99 | ) 100 | 101 | 102 | class ContactsTable(Component[ContactsSlice, Attrs]): 103 | @override 104 | def render(self) -> Table[TableHead, ContactsSlice]: 105 | return Table( 106 | TableHead("ID", "Name", "Email"), 107 | *self.children, 108 | classes=["text-align-center"], 109 | ) 110 | -------------------------------------------------------------------------------- /examples/delete_row.py: -------------------------------------------------------------------------------- 1 | from typing import Self, override 2 | 3 | from examples import Page, init_db 4 | 5 | from ludic.attrs import Attrs, GlobalAttrs 6 | from ludic.catalog.buttons import ButtonDanger 7 | from ludic.catalog.headers import H1, H2 8 | from ludic.catalog.quotes import Quote 9 | from ludic.catalog.tables import Table, TableHead, TableRow 10 | from ludic.web import Endpoint, LudicApp 11 | from ludic.web.exceptions import NotFoundError 12 | 13 | db = init_db() 14 | app = LudicApp(debug=True) 15 | 16 | 17 | class PersonAttrs(Attrs): 18 | id: str 19 | name: str 20 | email: str 21 | active: bool 22 | 23 | 24 | class PeopleAttrs(Attrs): 25 | people: list[PersonAttrs] 26 | 27 | 28 | @app.get("/") 29 | def index() -> Page: 30 | return Page( 31 | H1("Delete Row"), 32 | Quote( 33 | "This example shows how to implement a delete button that removes " 34 | "a table row upon completion.", 35 | source_url="https://htmx.org/examples/delete-row/", 36 | ), 37 | H2("Demo"), 38 | PeopleTable.get(), 39 | ) 40 | 41 | 42 | @app.endpoint("/people/{id}") 43 | class PersonRow(Endpoint[PersonAttrs]): 44 | @classmethod 45 | def delete(cls, id: str) -> None: 46 | try: 47 | db.people.pop(id) 48 | except KeyError: 49 | raise NotFoundError("Person not found") 50 | 51 | @override 52 | def render(self) -> TableRow: 53 | return TableRow( 54 | self.attrs["name"], 55 | self.attrs["email"], 56 | "Active" if self.attrs["active"] else "Inactive", 57 | ButtonDanger( 58 | "Delete", hx_delete=self.url_for(PersonRow), classes=["small"] 59 | ), 60 | ) 61 | 62 | 63 | @app.endpoint("/people/") 64 | class PeopleTable(Endpoint[PeopleAttrs]): 65 | styles = { 66 | "tr.htmx-swapping td": { 67 | "opacity": "0", 68 | "transition": "opacity 1s ease-out", 69 | } 70 | } 71 | 72 | @classmethod 73 | def get(cls) -> Self: 74 | return cls(people=[person.to_dict() for person in db.people.values()]) 75 | 76 | @override 77 | def render(self) -> Table[TableHead, PersonRow]: 78 | return Table( 79 | TableHead("Name", "Email", "Active", ""), 80 | *(PersonRow(**person) for person in self.attrs["people"]), 81 | body_attrs=GlobalAttrs( 82 | hx_confirm="Are you sure?", 83 | hx_target="closest tr", 84 | hx_swap="outerHTML swap:1s", 85 | ), 86 | classes=["text-align-center"], 87 | ) 88 | -------------------------------------------------------------------------------- /examples/edit_row.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, NotRequired, Self, override 2 | 3 | from examples import Page, init_db 4 | 5 | from ludic.attrs import Attrs, GlobalAttrs 6 | from ludic.catalog.buttons import ( 7 | ButtonPrimary, 8 | ButtonSecondary, 9 | ButtonSuccess, 10 | ) 11 | from ludic.catalog.forms import InputField 12 | from ludic.catalog.headers import H1, H2 13 | from ludic.catalog.layouts import Cluster 14 | from ludic.catalog.quotes import Quote 15 | from ludic.catalog.tables import ColumnMeta, Table, TableHead, TableRow 16 | from ludic.types import JavaScript 17 | from ludic.web import Endpoint, LudicApp 18 | from ludic.web.exceptions import NotFoundError 19 | from ludic.web.parsers import Parser 20 | 21 | db = init_db() 22 | app = LudicApp(debug=True) 23 | 24 | 25 | class PersonAttrs(Attrs): 26 | id: NotRequired[str] 27 | name: Annotated[str, ColumnMeta()] 28 | email: Annotated[str, ColumnMeta()] 29 | 30 | 31 | class PeopleAttrs(Attrs): 32 | people: list[PersonAttrs] 33 | 34 | 35 | @app.get("/") 36 | async def index() -> Page: 37 | return Page( 38 | H1("Edit Row"), 39 | Quote( 40 | "This example shows how to implement editable rows.", 41 | source_url="https://htmx.org/examples/edit-row/", 42 | ), 43 | H2("Demo"), 44 | await PeopleTable.get(), 45 | ) 46 | 47 | 48 | @app.endpoint("/people/{id}") 49 | class PersonRow(Endpoint[PersonAttrs]): 50 | on_click_script: JavaScript = JavaScript( 51 | """ 52 | let editing = document.querySelector('.editing') 53 | 54 | if (editing) { 55 | alert('You are already editing a row') 56 | } else { 57 | htmx.trigger(this, 'edit') 58 | } 59 | """ 60 | ) 61 | 62 | @classmethod 63 | async def put(cls, id: str, data: Parser[PersonAttrs]) -> Self: 64 | person = db.people.get(id) 65 | 66 | if person is None: 67 | raise NotFoundError("Person not found") 68 | 69 | for attr, value in data.validate().items(): 70 | setattr(person, attr, value) 71 | 72 | return cls(**person.to_dict()) 73 | 74 | @classmethod 75 | async def get(cls, id: str) -> Self: 76 | person = db.people.get(id) 77 | 78 | if person is None: 79 | raise NotFoundError("Person not found") 80 | 81 | return cls(**person.to_dict()) 82 | 83 | @override 84 | def render(self) -> TableRow: 85 | return TableRow( 86 | self.attrs["name"], 87 | self.attrs["email"], 88 | ButtonPrimary( 89 | "Edit", 90 | hx_get=self.url_for(PersonForm).path, 91 | hx_trigger="edit", 92 | on_click=self.on_click_script, 93 | classes=["small"], 94 | ), 95 | ) 96 | 97 | 98 | @app.endpoint("/people/{id}/form/") 99 | class PersonForm(Endpoint[PersonAttrs]): 100 | @classmethod 101 | async def get(cls, id: str) -> Self: 102 | person = db.people.get(id) 103 | 104 | if person is None: 105 | raise NotFoundError("Person not found") 106 | 107 | return cls(**person.to_dict()) 108 | 109 | @override 110 | def render(self) -> TableRow: 111 | return TableRow( 112 | InputField(name="name", value=self.attrs["name"]), 113 | InputField(name="email", value=self.attrs["email"]), 114 | Cluster( 115 | ButtonSecondary( 116 | "Cancel", 117 | hx_get=self.url_for(PersonRow), 118 | classes=["small"], 119 | ), 120 | ButtonSuccess( 121 | "Save", 122 | hx_put=self.url_for(PersonRow), 123 | hx_include="closest tr", 124 | classes=["small"], 125 | ), 126 | classes=["cluster-small", "centered"], 127 | ), 128 | classes=["editing"], 129 | ) 130 | 131 | 132 | @app.endpoint("/people/") 133 | class PeopleTable(Endpoint[PeopleAttrs]): 134 | @classmethod 135 | async def get(cls) -> Self: 136 | return cls(people=[person.to_dict() for person in db.people.values()]) 137 | 138 | @override 139 | def render(self) -> Table[TableHead, PersonRow]: 140 | return Table[TableHead, PersonRow]( 141 | TableHead("Name", "Email", "Action"), 142 | *(PersonRow(**person) for person in self.attrs["people"]), 143 | body_attrs=GlobalAttrs(hx_target="closest tr", hx_swap="outerHTML"), 144 | classes=["text-align-center"], 145 | ) 146 | -------------------------------------------------------------------------------- /examples/fastapi_example.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from examples import DB as Database 4 | from examples import Page, init_db 5 | from fastapi import Depends, FastAPI 6 | 7 | from ludic.catalog.forms import Option, SelectField, SelectFieldAttrs 8 | from ludic.catalog.headers import H1, H2 9 | from ludic.catalog.layouts import Stack 10 | from ludic.catalog.loaders import LazyLoader 11 | from ludic.catalog.messages import MessageInfo, Title 12 | from ludic.catalog.quotes import Quote 13 | from ludic.catalog.typography import Code 14 | from ludic.components import Component 15 | from ludic.contrib.fastapi import LudicRoute 16 | from ludic.html import b 17 | from ludic.web import Request 18 | from ludic.web.exceptions import NotFoundError 19 | 20 | app = FastAPI() 21 | app.router.route_class = LudicRoute 22 | 23 | 24 | class CarSelect(Component[str, SelectFieldAttrs]): 25 | """Ludic element representing car select.""" 26 | 27 | @override 28 | def render(self) -> SelectField: 29 | return SelectField( 30 | *[Option(child, value=child.lower()) for child in self.children], 31 | label=self.attrs.pop("label", "Car Manufacturer"), 32 | name="manufacturer", 33 | **self.attrs, 34 | ) 35 | 36 | 37 | class CarModelsSelect(Component[str, SelectFieldAttrs]): 38 | """Ludic element representing car models select.""" 39 | 40 | @override 41 | def render(self) -> SelectField: 42 | return SelectField( 43 | *[Option(child, value=child.lower()) for child in self.children], 44 | label=self.attrs.pop("label", "Car Model"), 45 | id="models", 46 | **self.attrs, 47 | ) 48 | 49 | 50 | @app.get("/") 51 | async def index(request: Request) -> Page: 52 | return Page( 53 | H1("Cascading Select"), 54 | MessageInfo( 55 | Title("FastAPI Example"), 56 | f"This example uses {b("FastAPI")} as backend Web Framework.", 57 | ), 58 | Quote( 59 | f"In this example we show how to make the values in one {Code("select")} " 60 | f"depend on the value selected in another {Code("select")}.", 61 | source_url="https://htmx.org/examples/value-select/", 62 | ), 63 | H2("Demo"), 64 | LazyLoader(load_url=request.url_for(cars)), 65 | ) 66 | 67 | 68 | @app.get("/cars/") 69 | def cars(request: Request, db: Database = Depends(init_db)) -> Stack: 70 | return Stack( 71 | CarSelect( 72 | *db.find_all_cars_names(), 73 | hx_get=request.url_for(models), 74 | hx_target="#models", 75 | ), 76 | CarModelsSelect(*db.find_first_car().models), 77 | ) 78 | 79 | 80 | @app.get("/models/") 81 | def models( 82 | manufacturer: str | None = None, db: Database = Depends(init_db) 83 | ) -> CarModelsSelect: 84 | if car := db.find_car_by_name(manufacturer): 85 | return CarModelsSelect(*car.models, label=None) # type: ignore 86 | else: 87 | raise NotFoundError("Car could not be found") 88 | -------------------------------------------------------------------------------- /examples/infinite_scroll.py: -------------------------------------------------------------------------------- 1 | from typing import Self, override 2 | 3 | from examples import Page 4 | 5 | from ludic.attrs import Attrs 6 | from ludic.catalog.headers import H1, H2 7 | from ludic.catalog.quotes import Quote 8 | from ludic.catalog.tables import Table, TableHead, TableRow 9 | from ludic.components import Component 10 | from ludic.elements import Blank 11 | from ludic.web import Endpoint, LudicApp 12 | from ludic.web.datastructures import QueryParams 13 | 14 | app = LudicApp(debug=True) 15 | 16 | 17 | class ContactAttrs(Attrs): 18 | id: str 19 | name: str 20 | email: str 21 | 22 | 23 | class ContactsSliceAttrs(Attrs): 24 | page: int 25 | contacts: list[ContactAttrs] 26 | 27 | 28 | def load_contacts(page: int) -> list[ContactAttrs]: 29 | return [ 30 | ContactAttrs( 31 | id=str(page * 10 + idx), 32 | email=f"void{page * 10 + idx}@null.org", 33 | name="Agent Smith", 34 | ) 35 | for idx in range(10) 36 | ] 37 | 38 | 39 | @app.get("/") 40 | async def index() -> Page: 41 | return Page( 42 | H1("Infinite Scroll"), 43 | Quote( 44 | "The infinite scroll pattern provides a way to load content dynamically" 45 | "on user scrolling action.", 46 | source_url="https://htmx.org/examples/infinite-scroll/", 47 | ), 48 | H2("Demo"), 49 | ContactsTable(await ContactsSlice.get(QueryParams(page=1))), 50 | ) 51 | 52 | 53 | @app.endpoint("/contacts/") 54 | class ContactsSlice(Endpoint[ContactsSliceAttrs]): 55 | @classmethod 56 | async def get(cls, params: QueryParams) -> Self: 57 | page = int(params.get("page", 1)) 58 | return cls(page=page, contacts=load_contacts(page)) 59 | 60 | @override 61 | def render(self) -> Blank[TableRow]: 62 | *init, last = ( 63 | (contact["id"], contact["name"], contact["email"]) 64 | for contact in self.attrs["contacts"] 65 | ) 66 | return Blank( 67 | *(TableRow(*rows) for rows in init), 68 | TableRow( 69 | *last, 70 | hx_get=self.url_for(ContactsSlice).include_query_params( 71 | page=self.attrs["page"] + 1 72 | ), 73 | hx_trigger="revealed", 74 | hx_swap="afterend", 75 | ), 76 | ) 77 | 78 | 79 | class ContactsTable(Component[ContactsSlice, Attrs]): 80 | @override 81 | def render(self) -> Table[TableHead, ContactsSlice]: 82 | return Table( 83 | TableHead("ID", "Name", "Email"), 84 | *self.children, 85 | classes=["text-align-center"], 86 | ) 87 | -------------------------------------------------------------------------------- /examples/lazy_loading.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from examples import Page 4 | 5 | from ludic.catalog.headers import H1, H2, H3 6 | from ludic.catalog.layouts import Box 7 | from ludic.catalog.loaders import LazyLoader 8 | from ludic.catalog.quotes import Quote 9 | from ludic.web import LudicApp, Request 10 | 11 | app = LudicApp(debug=True) 12 | 13 | 14 | @app.get("/") 15 | async def index(request: Request) -> Page: 16 | return Page( 17 | H1("Lazy Loading"), 18 | Quote( 19 | "This example shows how to lazily load an element on a page.", 20 | source_url="https://htmx.org/examples/lazy-load/", 21 | ), 22 | H2("Demo"), 23 | LazyLoader(load_url=request.url_for(load_svg, seconds=3)), 24 | ) 25 | 26 | 27 | @app.get("/load/{seconds:int}") 28 | async def load_svg(seconds: int) -> Box: 29 | await asyncio.sleep(seconds) 30 | return Box( 31 | H3("Content Loaded!", classes=["text-align-center"]), 32 | classes=["invert"], 33 | style={"padding-top": "10rem", "padding-bottom": "10rem"}, 34 | ) 35 | -------------------------------------------------------------------------------- /ludic/__init__.py: -------------------------------------------------------------------------------- 1 | from ludic.attrs import Attrs, GlobalAttrs, NoAttrs 2 | from ludic.components import Block, Component, ComponentStrict, Inline 3 | from ludic.elements import Blank 4 | 5 | __all__ = ( 6 | "Attrs", 7 | "GlobalAttrs", 8 | "NoAttrs", 9 | "Block", 10 | "Component", 11 | "ComponentStrict", 12 | "Inline", 13 | "Blank", 14 | ) 15 | -------------------------------------------------------------------------------- /ludic/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from collections.abc import Iterator, Mapping, Sequence 3 | from typing import Any, ClassVar 4 | 5 | from .format import FormatContext, format_attrs, format_element 6 | 7 | 8 | class BaseElement(metaclass=ABCMeta): 9 | html_header: ClassVar[str | None] = None 10 | html_name: ClassVar[str | None] = None 11 | void_element: ClassVar[bool] = False 12 | 13 | formatter: ClassVar[FormatContext] = FormatContext("element_formatter") 14 | formatter_fstring_wrap_in: ClassVar[type["BaseElement"] | None] = None 15 | 16 | children: Sequence[Any] 17 | attrs: Mapping[str, Any] 18 | context: dict[str, Any] 19 | 20 | def __init__(self, *children: Any, **attrs: Any) -> None: 21 | self.context = {} 22 | self.children = self.formatter.extract( 23 | *children, WrapIn=self.formatter_fstring_wrap_in 24 | ) 25 | self.attrs = attrs 26 | 27 | def __str__(self) -> str: 28 | return self.to_html() 29 | 30 | def __bytes__(self) -> bytes: 31 | return self.to_html().encode("utf-8") 32 | 33 | def __format__(self, _: str) -> str: 34 | return self.formatter.append(self) 35 | 36 | def __len__(self) -> int: 37 | return len(self.children) 38 | 39 | def __iter__(self) -> Iterator[Any]: 40 | return iter(self.children) 41 | 42 | def __repr__(self) -> str: 43 | return self.to_string(pretty=False) 44 | 45 | def __eq__(self, other: Any) -> bool: 46 | return ( 47 | type(self) is type(other) 48 | and self.children == other.children 49 | and self.attrs == other.attrs 50 | ) 51 | 52 | def _format_attributes(self, is_html: bool = False) -> str: 53 | attrs: dict[str, Any] = format_attrs(self.attrs, is_html=is_html) 54 | return " ".join( 55 | f'{key}="{value}"' if '"' not in value else f"{key}='{value}'" 56 | for key, value in attrs.items() 57 | ) 58 | 59 | def _format_children(self) -> str: 60 | formatted = "" 61 | for child in self.children: 62 | if self.context and isinstance(child, BaseElement): 63 | child.context.update(self.context) 64 | formatted += format_element(child) 65 | return formatted 66 | 67 | @property 68 | def aliased_attrs(self) -> dict[str, Any]: 69 | """Attributes as a dict with keys renamed to their aliases.""" 70 | return format_attrs(self.attrs) 71 | 72 | @property 73 | def text(self) -> str: 74 | """Get the text content of the element.""" 75 | return "".join( 76 | child.text if isinstance(child, BaseElement) else str(child) 77 | for child in self.children 78 | ) 79 | 80 | def is_simple(self) -> bool: 81 | """Check if the element is simple (i.e. contains only one primitive type).""" 82 | return len(self) == 1 and isinstance(self.children[0], str | int | float | bool) 83 | 84 | def has_attributes(self) -> bool: 85 | """Check if the element has any attributes.""" 86 | return bool(self.attrs) 87 | 88 | def to_string(self, pretty: bool = True, _level: int = 0) -> str: 89 | """Convert the element tree to a string representation. 90 | 91 | Args: 92 | pretty (bool, optional): Whether to indent the string. Defaults to True. 93 | 94 | Returns: 95 | str: The string representation of the element tree. 96 | """ 97 | indent = " " * _level if pretty else "" 98 | name = self.__class__.__name__ 99 | element = f"<{name}" 100 | 101 | if self.has_attributes(): 102 | element += f" {self._format_attributes()}" 103 | 104 | if self.children: 105 | prefix, sep, suffix = "", "", "" 106 | if pretty and (not self.is_simple() or self.has_attributes()): 107 | prefix, sep, suffix = f"\n{indent} ", f"\n{indent} ", f"\n{indent}" 108 | 109 | children_str = sep.join( 110 | child.to_string(pretty=pretty, _level=_level + 1) 111 | if isinstance(child, BaseElement) 112 | else str(child) 113 | for child in self.children 114 | ) 115 | 116 | element += f">{prefix}{children_str}{suffix}" 117 | else: 118 | element += " />" 119 | 120 | return element 121 | 122 | def to_html(self) -> str: 123 | """Convert an element tree to an HTML string.""" 124 | element_tag = f"{self.html_header}\n" if self.html_header else "" 125 | children_str = self._format_children() if self.children else "" 126 | 127 | element_tag += f"<{self.html_name}" 128 | if self.has_attributes(): 129 | attributes_str = self._format_attributes(is_html=True) 130 | element_tag += f" {attributes_str}" 131 | 132 | if not self.void_element: 133 | element_tag += f">{children_str}" 134 | else: 135 | element_tag += ">" 136 | 137 | return element_tag 138 | -------------------------------------------------------------------------------- /ludic/catalog/__init__.py: -------------------------------------------------------------------------------- 1 | """A collection of Ludic components. 2 | 3 | This module is meant as a collection of components that could be useful 4 | for building Ludic applications. Any contributor is welcome to add new ones. 5 | 6 | It also serves as showcase of possible implementations. 7 | """ 8 | 9 | # these need to be imported as they load important styling options 10 | from . import pages, layouts # noqa 11 | -------------------------------------------------------------------------------- /ludic/catalog/buttons.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import ButtonAttrs 4 | from ludic.components import ComponentStrict 5 | from ludic.html import button, style 6 | from ludic.types import PrimitiveChildren 7 | 8 | from .typography import Link 9 | 10 | 11 | class Button(ComponentStrict[PrimitiveChildren, ButtonAttrs]): 12 | """Simple component creating an HTML button. 13 | 14 | The component creates a button. 15 | """ 16 | 17 | classes = ["btn"] 18 | styles = style.use( 19 | lambda theme: { 20 | ".btn": { 21 | "display": "inline-block", 22 | "text-decoration": "none", 23 | "background-color": theme.colors.light, 24 | "color": theme.colors.black, 25 | "padding": f"{theme.sizes.xxxxs * 0.8} {theme.sizes.xs}", 26 | "border": "none", 27 | "border-radius": theme.rounding.normal, 28 | "font-size": theme.fonts.size, 29 | "transition": "0.1s filter linear, 0.1s -webkit-filter linear", 30 | }, 31 | ".btn:enabled": { 32 | "cursor": "pointer", 33 | }, 34 | ":not(a).btn": { 35 | "background-color": theme.colors.light.darken(2), 36 | }, 37 | (".btn:hover", ".btn:focus"): { 38 | "filter": "brightness(85%)", 39 | "text-decoration": "none", 40 | }, 41 | ".btn:disabled": { 42 | "filter": "opacity(50%)", 43 | }, 44 | ".btn.small": { 45 | "font-size": theme.fonts.size * 0.9, 46 | "padding": f"{theme.sizes.xxxxs * 0.6} {theme.sizes.xxs}", 47 | }, 48 | ".btn.large": { 49 | "font-size": theme.fonts.size * 1.2, 50 | "padding": f"{theme.sizes.xxxxs} {theme.sizes.m}", 51 | }, 52 | (".invert .btn", ".active .btn"): { 53 | "background-color": theme.colors.dark, 54 | "color": theme.colors.light, 55 | "border-color": theme.colors.dark, 56 | }, 57 | } 58 | ) 59 | 60 | @override 61 | def render(self) -> button: 62 | return button(self.children[0], **self.attrs) 63 | 64 | 65 | class ButtonPrimary(Button): 66 | """Simple component creating an HTML button. 67 | 68 | The component creates a button with the ``primary`` class. 69 | """ 70 | 71 | classes = ["btn", "primary"] 72 | styles = style.use( 73 | lambda theme: { 74 | ".btn.primary": { 75 | "color": theme.colors.primary.readable(), 76 | "background-color": theme.colors.primary, 77 | } 78 | } 79 | ) 80 | 81 | 82 | class ButtonSecondary(Button): 83 | """Simple component creating an HTML button. 84 | 85 | The component creates a button with the ``secondary`` class. 86 | """ 87 | 88 | classes = ["btn", "secondary"] 89 | styles = style.use( 90 | lambda theme: { 91 | ".btn.secondary": { 92 | "color": theme.colors.secondary.readable(), 93 | "background-color": theme.colors.secondary, 94 | } 95 | } 96 | ) 97 | 98 | 99 | class ButtonLink(Link): 100 | """Simple component creating an HTML button. 101 | 102 | The component creates a button with the ``secondary`` class. 103 | """ 104 | 105 | classes = ["btn"] 106 | 107 | 108 | class ButtonSuccess(Button): 109 | """Simple component creating an HTML button. 110 | 111 | The component creates a button with the ``success`` class. 112 | """ 113 | 114 | classes = ["btn", "success"] 115 | styles = style.use( 116 | lambda theme: { 117 | ".btn.success": { 118 | "color": theme.colors.success.readable(), 119 | "background-color": theme.colors.success, 120 | }, 121 | } 122 | ) 123 | 124 | 125 | class ButtonDanger(Button): 126 | """Simple component creating an HTML button. 127 | 128 | The component creates a button with the ``danger`` class. 129 | """ 130 | 131 | classes = ["btn", "danger"] 132 | styles = style.use( 133 | lambda theme: { 134 | ".btn.danger": { 135 | "color": theme.colors.danger.readable(), 136 | "background-color": theme.colors.danger, 137 | }, 138 | } 139 | ) 140 | 141 | 142 | class ButtonWarning(Button): 143 | """Simple component creating an HTML button. 144 | 145 | The component creates a button with the ``warning`` class. 146 | """ 147 | 148 | classes = ["btn", "warning"] 149 | styles = style.use( 150 | lambda theme: { 151 | ".btn.warning": { 152 | "color": theme.colors.warning.readable(), 153 | "background-color": theme.colors.warning, 154 | } 155 | } 156 | ) 157 | 158 | 159 | class ButtonInfo(Button): 160 | """Simple component creating an HTML button. 161 | 162 | The component creates a button with the ``info`` class. 163 | """ 164 | 165 | classes = ["btn", "info"] 166 | styles = style.use( 167 | lambda theme: { 168 | ".btn.info": { 169 | "color": theme.colors.info.readable(), 170 | "background-color": theme.colors.info, 171 | } 172 | } 173 | ) 174 | -------------------------------------------------------------------------------- /ludic/catalog/headers.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import Attrs, GlobalAttrs 4 | from ludic.components import Component, ComponentStrict 5 | from ludic.html import a, div, h1, h2, h3, h4, style 6 | 7 | from .utils import text_to_kebab 8 | 9 | 10 | class AnchorAttrs(Attrs): 11 | target: str 12 | 13 | 14 | class Anchor(Component[str, AnchorAttrs]): 15 | """Component representing a clickable anchor.""" 16 | 17 | classes = ["anchor"] 18 | styles = style.use( 19 | lambda theme: { 20 | "a.anchor": { 21 | "font-family": theme.fonts.secondary, 22 | "color": theme.colors.light.darken(1), 23 | "text-decoration": "none", 24 | }, 25 | "a.anchor:hover": { 26 | "color": theme.colors.dark, 27 | "text-decoration": "none", 28 | }, 29 | } 30 | ) 31 | 32 | @override 33 | def render(self) -> a: 34 | return a( 35 | self.children[0] if self.children else "#", href=f"#{self.attrs["target"]}" 36 | ) 37 | 38 | 39 | class WithAnchorAttrs(GlobalAttrs, total=False): 40 | anchor: Anchor | bool 41 | 42 | 43 | class WithAnchor(ComponentStrict[h1 | h2 | h3 | h4 | str, WithAnchorAttrs]): 44 | """Component which renders its content (header) with a clickable anchor.""" 45 | 46 | classes = ["with-anchor"] 47 | styles = style.use( 48 | lambda theme: { 49 | ".with-anchor": { 50 | "display": "flex", 51 | "flex-wrap": "wrap", 52 | "justify-content": "flex-start", 53 | }, 54 | ".with-anchor > h1 + a": { 55 | "margin-inline-start": theme.sizes.m, 56 | "font-size": theme.headers.h1.size, 57 | "line-height": round(theme.line_height * 0.9, 2), 58 | }, 59 | ".with-anchor > h2 + a": { 60 | "margin-inline-start": theme.sizes.s, 61 | "font-size": theme.headers.h2.size, 62 | "line-height": round(theme.line_height * 0.9, 2), 63 | }, 64 | ".with-anchor > h3 + a": { 65 | "margin-inline-start": theme.sizes.xs, 66 | "font-size": theme.headers.h3.size, 67 | "line-height": round(theme.line_height * 0.9, 2), 68 | }, 69 | ".with-anchor > h4 + a": { 70 | "margin-inline-start": theme.sizes.xxs, 71 | "font-size": theme.headers.h4.size, 72 | "line-height": round(theme.line_height * 0.9, 2), 73 | }, 74 | } 75 | ) 76 | 77 | @override 78 | def render(self) -> div: 79 | element: h1 | h2 | h3 | h4 80 | if isinstance(self.children[0], h1 | h2 | h3 | h4): 81 | element = self.children[0] 82 | else: 83 | element = h1(*self.children) 84 | 85 | element.attrs.setdefault("id", text_to_kebab(element.text)) 86 | id = element.attrs["id"] 87 | 88 | return div( 89 | element, 90 | ( 91 | self.attrs["anchor"] 92 | if isinstance(self.attrs.get("anchor"), Anchor) 93 | else Anchor(target=id) 94 | ), 95 | ) 96 | 97 | 98 | class H1(ComponentStrict[str, WithAnchorAttrs]): 99 | """Component rendering as h1 with an optional clickable anchor.""" 100 | 101 | @override 102 | def render(self) -> h1 | WithAnchor: 103 | header = h1(*self.children, **self.attrs_for(h1)) 104 | anchor = self.attrs.get("anchor") 105 | if anchor: 106 | return WithAnchor(header, anchor=anchor) 107 | elif self.theme.headers.h1.anchor and anchor is not False: 108 | return WithAnchor(header) 109 | else: 110 | return header 111 | 112 | 113 | class H2(ComponentStrict[str, WithAnchorAttrs]): 114 | """Component rendering as h2 with an optional clickable anchor.""" 115 | 116 | @override 117 | def render(self) -> h2 | WithAnchor: 118 | header = h2(*self.children, **self.attrs_for(h2)) 119 | anchor = self.attrs.get("anchor") 120 | if anchor: 121 | return WithAnchor(header, anchor=anchor) 122 | elif self.theme.headers.h2.anchor and anchor is not False: 123 | return WithAnchor(header) 124 | else: 125 | return header 126 | 127 | 128 | class H3(ComponentStrict[str, WithAnchorAttrs]): 129 | """Component rendering as h3 with an optional clickable anchor.""" 130 | 131 | @override 132 | def render(self) -> h3 | WithAnchor: 133 | header = h3(*self.children, **self.attrs_for(h3)) 134 | anchor = self.attrs.get("anchor") 135 | if anchor: 136 | return WithAnchor(header, anchor=anchor) 137 | elif self.theme.headers.h3.anchor and anchor is not False: 138 | return WithAnchor(header) 139 | else: 140 | return header 141 | 142 | 143 | class H4(ComponentStrict[str, WithAnchorAttrs]): 144 | """Component rendering as h4 with an optional clickable anchor.""" 145 | 146 | @override 147 | def render(self) -> h4 | WithAnchor: 148 | header = h4(*self.children, **self.attrs_for(h4)) 149 | anchor = self.attrs.get("anchor") 150 | if anchor: 151 | return WithAnchor(header, anchor=anchor) 152 | elif self.theme.headers.h4.anchor and anchor is not False: 153 | return WithAnchor(header) 154 | else: 155 | return header 156 | -------------------------------------------------------------------------------- /ludic/catalog/icons.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import GlobalAttrs, ImgAttrs 4 | from ludic.components import Component, ComponentStrict 5 | from ludic.html import img, span, style 6 | from ludic.types import AnyChildren, NoChildren 7 | 8 | 9 | class Icon(Component[NoChildren, ImgAttrs]): 10 | classes = ["icon"] 11 | styles = { 12 | ".icon": { 13 | "width": "0.75em", 14 | "height": "0.75em", 15 | }, 16 | } 17 | 18 | @override 19 | def render(self) -> img: 20 | return img(*self.children, **self.attrs) 21 | 22 | 23 | class WithIcon(ComponentStrict[Icon, AnyChildren, GlobalAttrs]): 24 | classes = ["with-icon"] 25 | styles = style.use( 26 | lambda theme: { 27 | ".with-icon": { 28 | "display": "inline-flex", 29 | "align-items": "baseline", 30 | }, 31 | ".with-icon .icon": { 32 | "margin-inline-end": theme.sizes.s, 33 | }, 34 | } 35 | ) 36 | 37 | @override 38 | def render(self) -> span: 39 | return span(*self.children, **self.attrs) 40 | -------------------------------------------------------------------------------- /ludic/catalog/items.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import override 3 | 4 | from ludic.attrs import GlobalAttrs 5 | from ludic.components import Component 6 | from ludic.html import dd, dl, dt 7 | from ludic.types import PrimitiveChildren 8 | 9 | from .utils import attr_to_camel 10 | 11 | 12 | class Key(Component[PrimitiveChildren, GlobalAttrs]): 13 | """Simple component rendering as the HTML ``dt`` element.""" 14 | 15 | @override 16 | def render(self) -> dt: 17 | return dt(*self.children, **self.attrs) 18 | 19 | 20 | class Value(Component[PrimitiveChildren, GlobalAttrs]): 21 | """Simple component rendering as the HTML ``dd`` element.""" 22 | 23 | @override 24 | def render(self) -> dd: 25 | return dd(*self.children, **self.attrs) 26 | 27 | 28 | class PairsAttrs(GlobalAttrs, total=False): 29 | """Attributes of the component ``Pairs``.""" 30 | 31 | items: Iterable[tuple[str, PrimitiveChildren]] 32 | 33 | 34 | class Pairs(Component[Key | Value, PairsAttrs]): 35 | """Simple component rendering as the HTML ``dl`` element. 36 | 37 | Example usage: 38 | 39 | Pairs( 40 | Key("Name"), 41 | Value("John"), 42 | Key("Age"), 43 | Value(42), 44 | ) 45 | 46 | The components accepts the ``items`` attribute, which allow the following usage: 47 | 48 | Pairs( 49 | items=[("name", "John"), ("age", 42)], 50 | ) 51 | 52 | Or alternatively: 53 | 54 | Pairs( 55 | items={"name": "John", "age": 42}.items(), 56 | ) 57 | """ 58 | 59 | @override 60 | def render(self) -> dl: 61 | from_items: list[Key | Value] = [] 62 | 63 | for key, value in self.attrs.get("items", ()): 64 | from_items.append(Key(attr_to_camel(key))) 65 | from_items.append(Value(value)) 66 | 67 | return dl(*from_items, *self.children, **self.attrs_for(dl)) 68 | -------------------------------------------------------------------------------- /ludic/catalog/lists.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import GlobalAttrs, OlAttrs 4 | from ludic.components import Component 5 | from ludic.html import li, ol, ul 6 | from ludic.types import AnyChildren 7 | 8 | 9 | class Item(Component[AnyChildren, GlobalAttrs]): 10 | """Simple component simulating an item in a list. 11 | 12 | Example usage: 13 | 14 | Item("Item 1") 15 | """ 16 | 17 | @override 18 | def render(self) -> li: 19 | return li(*self.children, **self.attrs) 20 | 21 | 22 | class List(Component[AnyChildren, GlobalAttrs]): 23 | """Simple component simulating a list. 24 | 25 | There is basically just an alias for the :class:`ul` element 26 | without the requirement to pass `li` as children. 27 | 28 | Example usage: 29 | 30 | List("Item 1", "Item 2") 31 | List(Item("Item 1"), Item("Item 2")) 32 | """ 33 | 34 | formatter_fstring_wrap_in = Item 35 | 36 | @override 37 | def render(self) -> ul: 38 | children = ( 39 | child if isinstance(child, Item) else Item(child) for child in self.children 40 | ) 41 | return ul(*children, **self.attrs_for(ul)) 42 | 43 | 44 | class NumberedList(Component[AnyChildren, OlAttrs]): 45 | """Simple component simulating a numbered list. 46 | 47 | There is basically just an alias for the :class:`ol` element 48 | without the requirement to pass `li` as children. 49 | 50 | Example usage: 51 | 52 | NumberedList("Item 1", "Item 2") 53 | NumberedList(Item("Item 1"), Item("Item 2")) 54 | """ 55 | 56 | formatter_fstring_wrap_in = Item 57 | 58 | @override 59 | def render(self) -> ol: 60 | children = ( 61 | child if isinstance(child, Item) else Item(child) for child in self.children 62 | ) 63 | return ol(*children, **self.attrs_for(ol)) 64 | -------------------------------------------------------------------------------- /ludic/catalog/loaders.py: -------------------------------------------------------------------------------- 1 | from typing import NotRequired, override 2 | 3 | from ludic.attrs import GlobalAttrs 4 | from ludic.components import Component 5 | from ludic.html import div, style 6 | from ludic.types import AnyChildren, URLType 7 | 8 | 9 | class Loading(Component[AnyChildren, GlobalAttrs]): 10 | classes = ["loader"] 11 | styles = style.use( 12 | lambda theme: { 13 | ".loader": { 14 | "text-align": "center", 15 | }, 16 | ".lds-ellipsis": { 17 | "display": "inline-block", 18 | "position": "relative", 19 | "width": theme.sizes.m * 5, 20 | "height": theme.sizes.m, 21 | "margin": theme.sizes.xl, 22 | }, 23 | ".lds-ellipsis div": { 24 | "position": "absolute", 25 | "width": theme.sizes.m, 26 | "height": theme.sizes.m, 27 | "border-radius": "50%", 28 | "background": theme.colors.dark, 29 | "animation-timing-function": "cubic-bezier(0, 1, 1, 0)", 30 | }, 31 | ".lds-ellipsis div:nth-child(1)": { 32 | "left": "8px", 33 | "animation": "lds-ellipsis1 0.6s infinite", 34 | }, 35 | ".lds-ellipsis div:nth-child(2)": { 36 | "left": "8px", 37 | "animation": "lds-ellipsis2 0.6s infinite", 38 | }, 39 | ".lds-ellipsis div:nth-child(3)": { 40 | "left": "32px", 41 | "animation": "lds-ellipsis2 0.6s infinite", 42 | }, 43 | ".lds-ellipsis div:nth-child(4)": { 44 | "left": "56px", 45 | "animation": "lds-ellipsis3 0.6s infinite", 46 | }, 47 | "@keyframes lds-ellipsis1": { 48 | "0%": { 49 | "transform": "scale(0)", 50 | }, 51 | "100%": { 52 | "transform": "scale(1)", 53 | }, 54 | }, 55 | "@keyframes lds-ellipsis3": { 56 | "0%": { 57 | "transform": "scale(1)", 58 | }, 59 | "100%": { 60 | "transform": "scale(0)", 61 | }, 62 | }, 63 | "@keyframes lds-ellipsis2": { 64 | "0%": { 65 | "transform": "translate(0, 0)", 66 | }, 67 | "100%": { 68 | "transform": "translate(24px, 0)", 69 | }, 70 | }, 71 | } 72 | ) 73 | 74 | @override 75 | def render(self) -> div: 76 | return div( 77 | div(div(), div(), div(), div(), classes=["lds-ellipsis"]), 78 | **self.attrs_for(div), 79 | ) 80 | 81 | 82 | class LazyLoaderAttrs(GlobalAttrs): 83 | load_url: URLType 84 | placeholder: NotRequired[AnyChildren] 85 | 86 | 87 | class LazyLoader(Component[AnyChildren, LazyLoaderAttrs]): 88 | """Lazy loader component using HTMX attributes. 89 | 90 | Usage: 91 | 92 | LazyLoader( 93 | load_url="https://example.com/svg-file" 94 | ) 95 | """ 96 | 97 | @override 98 | def render(self) -> div: 99 | self.attrs.setdefault("hx_trigger", "load") 100 | self.attrs.setdefault("hx_get", self.attrs["load_url"]) 101 | self.attrs.setdefault("hx_swap", "outerHTML") 102 | return div(self.attrs.get("placeholder", Loading()), **self.attrs_for(div)) 103 | -------------------------------------------------------------------------------- /ludic/catalog/messages.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import GlobalAttrs 4 | from ludic.components import Component 5 | from ludic.html import div, style 6 | from ludic.types import AnyChildren 7 | 8 | 9 | class Title(Component[AnyChildren, GlobalAttrs]): 10 | classes = ["title"] 11 | 12 | @override 13 | def render(self) -> div: 14 | return div(*self.children) 15 | 16 | 17 | class Message(Component[AnyChildren, GlobalAttrs]): 18 | classes = ["message"] 19 | styles = style.use( 20 | lambda theme: { 21 | ".message": { 22 | "background-color": theme.colors.white, 23 | "border": ( 24 | f"{theme.borders.thin} solid {theme.colors.light.darken(1)}" 25 | ), 26 | "border-radius": theme.rounding.less, 27 | "font-size": theme.fonts.size * 0.9, 28 | }, 29 | ".message > .title": { 30 | "padding-inline": theme.sizes.l, 31 | "padding-block": theme.sizes.s, 32 | "color": theme.colors.light.readable(), 33 | "background-color": theme.colors.light, 34 | }, 35 | ".message > .content": { 36 | "padding-inline": theme.sizes.l, 37 | "padding-block": theme.sizes.s, 38 | }, 39 | } 40 | ) 41 | 42 | @override 43 | def render(self) -> div: 44 | if self.children and isinstance(self.children[0], Title): 45 | return div( 46 | self.children[0], 47 | div(*self.children[1:], classes=["content"]), 48 | **self.attrs, 49 | ) 50 | else: 51 | return div(div(*self.children, classes=["content"]), **self.attrs) 52 | 53 | 54 | class MessageSuccess(Message): 55 | classes = ["message", "success"] 56 | styles = style.use( 57 | lambda theme: { 58 | ".message.success > .title": { 59 | "color": theme.colors.success.lighten(1).readable(), 60 | "background-color": theme.colors.success.lighten(1), 61 | }, 62 | ".message.success": { 63 | "border-color": theme.colors.success, 64 | }, 65 | } 66 | ) 67 | 68 | 69 | class MessageInfo(Message): 70 | classes = ["message", "info"] 71 | styles = style.use( 72 | lambda theme: { 73 | ".message.info > .title": { 74 | "color": theme.colors.info.lighten(1).readable(), 75 | "background-color": theme.colors.info.lighten(1), 76 | }, 77 | ".message.info": { 78 | "border-color": theme.colors.info, 79 | }, 80 | } 81 | ) 82 | 83 | 84 | class MessageWarning(Message): 85 | classes = ["message", "warning"] 86 | styles = style.use( 87 | lambda theme: { 88 | ".message.warning > .title": { 89 | "color": theme.colors.warning.lighten(1).readable(), 90 | "background-color": theme.colors.warning.lighten(1), 91 | }, 92 | ".message.warning": { 93 | "border-color": theme.colors.warning, 94 | }, 95 | } 96 | ) 97 | 98 | 99 | class MessageDanger(Message): 100 | classes = ["message", "danger"] 101 | styles = style.use( 102 | lambda theme: { 103 | ".message.danger > .title": { 104 | "color": theme.colors.danger.lighten(1).readable(), 105 | "background-color": theme.colors.danger.lighten(1), 106 | }, 107 | ".message.danger": { 108 | "border-color": theme.colors.danger, 109 | }, 110 | } 111 | ) 112 | -------------------------------------------------------------------------------- /ludic/catalog/navigation.py: -------------------------------------------------------------------------------- 1 | from typing import NotRequired, override 2 | 3 | from ludic.attrs import GlobalAttrs 4 | from ludic.components import Component, ComponentStrict 5 | from ludic.html import h2, li, nav, style, ul 6 | from ludic.types import PrimitiveChildren 7 | 8 | from .buttons import ButtonLink 9 | 10 | 11 | class NavItemAttrs(GlobalAttrs): 12 | to: str 13 | active: NotRequired[bool] 14 | active_subsection: NotRequired[bool] 15 | 16 | 17 | class NavHeader(ComponentStrict[str, GlobalAttrs]): 18 | """Simple component simulating a navigation header. 19 | 20 | This component is supposed to be used as a child of the :class:`Navigation` 21 | component. 22 | """ 23 | 24 | classes = ["nav-header"] 25 | styles = style.use( 26 | lambda theme: { 27 | "h2.nav-header": { 28 | "font-size": theme.headers.h2.size * 0.7, 29 | } 30 | } 31 | ) 32 | 33 | @override 34 | def render(self) -> h2: 35 | return h2(self.children[0], **self.attrs_for(h2)) 36 | 37 | 38 | class NavItem(Component[PrimitiveChildren, NavItemAttrs]): 39 | """Simple component simulating a navigation item. 40 | 41 | This component is supposed to be used as a child of the :class:`Navigation` 42 | component. 43 | """ 44 | 45 | classes = ["nav-item"] 46 | styles = style.use( 47 | lambda theme: { 48 | "li.nav-item.section.active-subsection .btn": { 49 | "font-weight": "bold", 50 | "background": "none", 51 | }, 52 | "li.nav-item.subsection": { 53 | "padding-inline-start": theme.sizes.m, 54 | }, 55 | } 56 | ) 57 | 58 | @override 59 | def render(self) -> li: 60 | self.attrs.setdefault("classes", []) 61 | 62 | if self.attrs.pop("active_subsection", False): 63 | self.attrs["classes"].append("active-subsection") 64 | 65 | if self.attrs.pop("active", False): 66 | self.attrs["classes"].append("active") 67 | 68 | return li( 69 | ButtonLink( 70 | self.children[0], 71 | to=self.attrs["to"], 72 | external=False, 73 | classes=["small"] if "subsection" in self.attrs["classes"] else [], 74 | ), 75 | **self.attrs_for(li), 76 | ) 77 | 78 | 79 | class NavSection(ComponentStrict[NavHeader, *tuple[NavItem, ...], GlobalAttrs]): 80 | """Simple component simulating a navigation section. 81 | 82 | This component is supposed to be used as a child of the :class:`Navigation` 83 | component. 84 | """ 85 | 86 | @override 87 | def render(self) -> li: 88 | self.attrs.setdefault("classes", ["stack", "tiny"]) 89 | return li( 90 | self.children[0], 91 | ul(*self.children[1:], classes=["stack", "tiny"]), 92 | **self.attrs_for(li), 93 | ) 94 | 95 | 96 | class Navigation(Component[NavItem | NavSection, GlobalAttrs]): 97 | """Simple component simulating a navigation bar. 98 | 99 | Example usage: 100 | 101 | Navigation( 102 | NavItem("Home", to="/"), 103 | NavItem("About", to="/about"), 104 | ) 105 | """ 106 | 107 | classes = ["navigation"] 108 | styles = { 109 | "nav.navigation ul": { 110 | "list-style": "none", 111 | "padding": "0", 112 | }, 113 | } 114 | 115 | @override 116 | def render(self) -> nav: 117 | return nav( 118 | ul(*self.children, classes=["stack", "small"]), 119 | **self.attrs_for(nav), 120 | ) 121 | -------------------------------------------------------------------------------- /ludic/catalog/pages.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import override 3 | 4 | from ludic.attrs import Attrs, NoAttrs 5 | from ludic.base import BaseElement 6 | from ludic.components import Component, ComponentStrict 7 | from ludic.html import body, head, html, link, meta, script, style, title 8 | from ludic.types import AnyChildren 9 | 10 | 11 | class HtmlHeadAttrs(Attrs, total=False): 12 | title: str 13 | favicon: str 14 | charset: str 15 | load_styles: bool 16 | htmx_config: dict[str, str] 17 | 18 | 19 | class HtmlBodyAttrs(Attrs, total=False): 20 | htmx_path: str 21 | htmx_enabled: bool 22 | htmx_version: str 23 | 24 | 25 | class Head(Component[AnyChildren, HtmlHeadAttrs]): 26 | @override 27 | def render(self) -> head: 28 | elements: list[BaseElement] = [title(self.attrs.get("title", "Ludic App"))] 29 | 30 | if favicon := self.attrs.get("favicon"): 31 | elements.append(link(rel="icon", href=favicon, type="image/x-icon")) 32 | if charset := self.attrs.get("charset", "utf-8"): 33 | elements.append(meta(charset=charset)) 34 | if config := self.attrs.get("htmx_config", {"defaultSwapStyle": "outerHTML"}): 35 | elements.append(meta(name="htmx-config", content=json.dumps(config))) 36 | if self.attrs.get("load_styles", True): 37 | elements.append(style.load(cache=True)) 38 | 39 | return head(*elements, *self.children) 40 | 41 | 42 | class Body(Component[AnyChildren, HtmlBodyAttrs]): 43 | @override 44 | def render(self) -> body: 45 | scripts = [] 46 | if htmx_path := self.attrs.get("htmx_path"): 47 | scripts.append(script(src=htmx_path)) 48 | elif self.attrs.get("htmx_enabled", "htmx_version" in self.attrs): 49 | htmx_version = self.attrs.get("htmx_version", "latest") 50 | scripts.append(script(src=f"https://unpkg.com/htmx.org@{htmx_version}")) 51 | return body(*self.children, *scripts) 52 | 53 | 54 | class HtmlPage(ComponentStrict[Head, Body, NoAttrs]): 55 | styles = style.use( 56 | lambda theme: { 57 | # global styling 58 | "*": { 59 | "box-sizing": "border-box", 60 | "max-inline-size": theme.measure, 61 | "font-family": theme.fonts.primary, 62 | "font-size": theme.fonts.size, 63 | "color": theme.colors.dark, 64 | "overflow-wrap": "break-word", 65 | "margin": "0", 66 | "padding": "0", 67 | "line-height": theme.line_height, 68 | }, 69 | "body": { 70 | "background-color": theme.colors.white, 71 | }, 72 | ("html", "body", "div", "header", "nav", "main", "footer"): { 73 | "max-inline-size": "none", 74 | }, 75 | # elements styling 76 | "h1": { 77 | "font-size": theme.headers.h1.size, 78 | "font-family": theme.fonts.secondary, 79 | "line-height": round(theme.line_height * 0.9, 2), 80 | }, 81 | "h2": { 82 | "font-size": theme.headers.h2.size, 83 | "font-family": theme.fonts.secondary, 84 | "line-height": round(theme.line_height * 0.9, 2), 85 | }, 86 | "h3": { 87 | "font-size": theme.headers.h3.size, 88 | "font-family": theme.fonts.secondary, 89 | "line-height": round(theme.line_height * 0.9, 2), 90 | }, 91 | "h4": { 92 | "font-size": theme.headers.h4.size, 93 | "font-family": theme.fonts.secondary, 94 | "line-height": round(theme.line_height * 0.9, 2), 95 | }, 96 | "h5": { 97 | "font-size": theme.headers.h5.size, 98 | "font-family": theme.fonts.secondary, 99 | "line-height": round(theme.line_height * 0.9, 2), 100 | }, 101 | "h6": { 102 | "font-size": theme.headers.h6.size, 103 | "font-family": theme.fonts.secondary, 104 | "line-height": round(theme.line_height * 0.9, 2), 105 | }, 106 | "a": { 107 | "color": theme.colors.primary, 108 | "text-decoration": "none", 109 | }, 110 | "a:hover": { 111 | "text-decoration": "underline", 112 | }, 113 | "pre": { 114 | "overflow": "auto", 115 | }, 116 | ("code", "pre", "pre *"): { 117 | "font-family": theme.fonts.monospace, 118 | }, 119 | "dl": { 120 | "margin-block": "0", 121 | }, 122 | "dl dd + dt": { 123 | "margin-block-start": theme.sizes.xs, 124 | }, 125 | "dl dt + dd": { 126 | "margin-block-start": theme.sizes.xxxxs, 127 | }, 128 | "dt": { 129 | "font-weight": "bold", 130 | }, 131 | "dd": { 132 | "margin-left": "0", 133 | }, 134 | ("ul", "ol"): { 135 | "padding-inline-start": theme.sizes.xl, 136 | }, 137 | ("ul > li + li", "ol > li + li", "li > * + *"): { 138 | "margin-block-start": theme.sizes.xxs, 139 | }, 140 | "ul > li::marker": { 141 | "font-size": theme.fonts.size * 1.2, 142 | }, 143 | ("img", "svg"): { 144 | "width": "100%", 145 | }, 146 | # utilities 147 | ".text-align-center": { 148 | "text-align": "center !important", # type: ignore[typeddict-item] 149 | }, 150 | ".text-align-right": { 151 | "text-align": "right !important", # type: ignore[typeddict-item] 152 | }, 153 | ".text-align-left": { 154 | "text-align": "left !important", # type: ignore[typeddict-item] 155 | }, 156 | ".justify-space-between": { 157 | "justify-content": "space-between !important", # type: ignore[typeddict-item] 158 | }, 159 | ".justify-space-around": { 160 | "justify-content": "space-around !important", # type: ignore[typeddict-item] 161 | }, 162 | ".justify-space-evenly": { 163 | "justify-content": "space-evenly !important", # type: ignore[typeddict-item] 164 | }, 165 | ".justify-center": { 166 | "justify-content": "center !important", # type: ignore[typeddict-item] 167 | }, 168 | ".justify-end": { 169 | "justify-content": "end !important", # type: ignore[typeddict-item] 170 | }, 171 | ".justify-start": { 172 | "justify-content": "start !important", # type: ignore[typeddict-item] 173 | }, 174 | ".align-center": { 175 | "align-items": "center !important", # type: ignore[typeddict-item] 176 | }, 177 | ".align-end": { 178 | "align-items": "end !important", # type: ignore[typeddict-item] 179 | }, 180 | ".align-start": { 181 | "align-items": "start !important", # type: ignore[typeddict-item] 182 | }, 183 | ".no-padding": { 184 | "padding": "0 !important", 185 | }, 186 | ".no-inline-padding": { 187 | "padding-inline": "0 !important", 188 | }, 189 | ".no-margin": { 190 | "margin": "0 !important", 191 | }, 192 | ".no-inline-margin": { 193 | "margin-inline": "0 !important", 194 | }, 195 | ".no-block-margin": { 196 | "margin-block": "0 !important", 197 | }, 198 | ".no-block-padding": { 199 | "padding-block": "0 !important", 200 | }, 201 | ".no-border": { 202 | "border": "none !important", 203 | "border-radius": "0 !important", 204 | }, 205 | ".no-border-radius": { 206 | "border-radius": "0 !important", 207 | }, 208 | } 209 | ) 210 | 211 | @override 212 | def render(self) -> html: 213 | return html( 214 | self.children[0].render(), 215 | self.children[1].render(), 216 | ) 217 | -------------------------------------------------------------------------------- /ludic/catalog/quotes.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import Attrs 4 | from ludic.base import BaseElement 5 | from ludic.components import ComponentStrict 6 | from ludic.html import a, blockquote, div, footer, p, style 7 | from ludic.types import AnyChildren 8 | 9 | 10 | class QuoteAttrs(Attrs, total=False): 11 | source_url: str 12 | source_text: str 13 | 14 | 15 | class Quote(ComponentStrict[str, QuoteAttrs]): 16 | """Simple component rendering as the HTML ``blockquote`` element.""" 17 | 18 | classes = ["quote"] 19 | styles = style.use( 20 | lambda theme: { 21 | ".quote": { 22 | "blockquote": { 23 | "background-color": theme.colors.light, 24 | "border-left": ( 25 | f"{theme.borders.thick} solid {theme.colors.light.darken(1)}" 26 | ), 27 | "margin-bottom": theme.sizes.xxs, 28 | "padding": f"{theme.sizes.l} {theme.sizes.m}", 29 | }, 30 | "blockquote p": { 31 | "font-size": theme.fonts.size, 32 | }, 33 | "blockquote code": { 34 | "background-color": theme.colors.white, 35 | }, 36 | "footer": { 37 | "font-size": theme.fonts.size * 0.9, 38 | "color": theme.colors.dark.lighten(1), 39 | }, 40 | "footer a": { 41 | "text-decoration": "none", 42 | "color": theme.colors.dark.darken(1), 43 | }, 44 | "footer a:hover": { 45 | "text-decoration": "underline", 46 | }, 47 | } 48 | } 49 | ) 50 | 51 | @override 52 | def render(self) -> div: 53 | p_children: list[BaseElement] = [] 54 | current_children: list[AnyChildren] = [] 55 | 56 | for child in self.children: 57 | if isinstance(child, str) and "\n\n" in child: 58 | p_children.append(p(*current_children)) 59 | current_children = [] 60 | else: 61 | current_children.append(child) 62 | 63 | children: list[BaseElement] = [blockquote(*p_children, p(*current_children))] 64 | if source_url := self.attrs.get("source_url"): 65 | source_text = self.attrs.get("source_text", "Source: ") 66 | children.append(footer(source_text, a(source_url, href=source_url))) 67 | return div(*children) 68 | -------------------------------------------------------------------------------- /ludic/catalog/tables.py: -------------------------------------------------------------------------------- 1 | """An experimental module for creating HTML tables.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from typing import Any, Literal, cast, get_type_hints, override 6 | 7 | from typing_extensions import TypeVar 8 | 9 | from ludic.attrs import GlobalAttrs 10 | from ludic.base import BaseElement 11 | from ludic.components import Component, ComponentStrict 12 | from ludic.html import div, style, table, tbody, td, th, thead, tr 13 | from ludic.types import ( 14 | AnyChildren, 15 | PrimitiveChildren, 16 | TAttrs, 17 | ) 18 | from ludic.utils import get_annotations_metadata_of_type 19 | 20 | from .forms import FieldMeta 21 | from .utils import attr_to_camel 22 | 23 | 24 | @dataclass 25 | class ColumnMeta: 26 | """Class to be used as an annotation for attributes. 27 | 28 | Example: 29 | 30 | class PersonAttrs(Attrs): 31 | id: str 32 | name: Annotated[ 33 | str, 34 | ColumnMeta(label="Full Name"), 35 | ] 36 | email: Annotated[ 37 | str, 38 | ColumnMeta(label="Email"), 39 | ] 40 | """ 41 | 42 | identifier: bool = False 43 | label: str | None = None 44 | kind: Literal["text"] | FieldMeta = "text" 45 | parser: Callable[[Any], PrimitiveChildren] | None = None 46 | 47 | def format(self, key: str, value: Any) -> Any: 48 | if isinstance(self.kind, FieldMeta): 49 | return self.kind.format(key, value) 50 | return value 51 | 52 | def parse(self, value: Any) -> PrimitiveChildren: 53 | if self.kind == "text": 54 | return value if self.parser is None else self.parser(value) 55 | return self.kind(value) 56 | 57 | def __call__(self, value: Any) -> PrimitiveChildren: 58 | return self.parse(value) 59 | 60 | 61 | class TableRow(Component[AnyChildren, GlobalAttrs]): 62 | """Simple component rendering as the HTML ``tr`` element.""" 63 | 64 | def get_value(self, index: int) -> PrimitiveChildren | None: 65 | if len(self.children) > index: 66 | child = self.children[index] 67 | return child.text if isinstance(child, BaseElement) else child 68 | return None 69 | 70 | @override 71 | def render(self) -> tr: 72 | return tr( 73 | *(child if isinstance(child, td) else td(child) for child in self.children), 74 | **self.attrs, 75 | ) 76 | 77 | 78 | class TableHead(Component[AnyChildren, GlobalAttrs]): 79 | """Simple component rendering as the HTML ``tr`` element.""" 80 | 81 | @property 82 | def header(self) -> tuple[PrimitiveChildren, ...]: 83 | return tuple( 84 | child.text if isinstance(child, BaseElement) else str(child) 85 | for child in self.children 86 | if self.children 87 | ) 88 | 89 | @override 90 | def render(self) -> tr: 91 | return tr( 92 | *(child if isinstance(child, th) else th(child) for child in self.children), 93 | **self.attrs, 94 | ) 95 | 96 | 97 | THead = TypeVar("THead", bound=BaseElement, default=TableHead) 98 | TRow = TypeVar("TRow", bound=BaseElement, default=TableRow) 99 | 100 | 101 | class TableAttrs(GlobalAttrs, total=False): 102 | head_attrs: GlobalAttrs 103 | body_attrs: GlobalAttrs 104 | 105 | 106 | class Table(ComponentStrict[THead, *tuple[TRow, ...], TableAttrs]): 107 | """A component rendering as the HTML ``table`` element. 108 | 109 | The component allows :class:`TableHead` and :class:`TableRow` types by default. 110 | 111 | Example: 112 | 113 | Table( 114 | TableHead("Name", "Age"), 115 | TableRow("John", 42), 116 | TableRow("Jane", 23), 117 | ) 118 | 119 | You can also specify different types of header and body: 120 | 121 | Table[PersonHead, PersonRow]( 122 | PersonHead("Name", "Age"), 123 | PersonRow("John", 42), 124 | ) 125 | """ 126 | 127 | classes = ["table"] 128 | styles = style.use( 129 | lambda theme: { 130 | ".table": { 131 | "overflow": "auto", 132 | }, 133 | ".table > table": { 134 | "inline-size": "100%", # type: ignore 135 | "border-collapse": "collapse", # type: ignore 136 | "thead": { 137 | "background-color": theme.colors.light, 138 | }, 139 | "tr th": { 140 | "border": ( 141 | f"{theme.borders.thin} solid {theme.colors.light.darken(1)}" 142 | ), 143 | "padding": f"{theme.sizes.xxs} {theme.sizes.xxxs}", 144 | }, 145 | "tr td": { 146 | "border": ( 147 | f"{theme.borders.thin} solid {theme.colors.light.darken(1)}" 148 | ), 149 | "padding": f"{theme.sizes.xxs} {theme.sizes.xxxs}", 150 | }, 151 | }, 152 | } 153 | ) 154 | 155 | @property 156 | def header(self) -> tuple[PrimitiveChildren, ...]: 157 | if isinstance(self.children[0], TableHead): 158 | return self.children[0].header 159 | return () 160 | 161 | def getlist(self, key: str) -> list[PrimitiveChildren | None]: 162 | result: list[PrimitiveChildren | None] = [] 163 | 164 | for idx, head in enumerate(self.header): 165 | if key != head: 166 | continue 167 | 168 | rows: list[TableRow] = cast(list[TableRow], self.children[1:]) 169 | for row in rows: 170 | if value := row.get_value(idx): 171 | result.append(value) 172 | 173 | return result 174 | 175 | @override 176 | def render(self) -> div: 177 | return div( 178 | table( 179 | thead(self.children[0], **self.attrs.get("head_attrs", {})), 180 | tbody(*self.children[1:], **self.attrs.get("body_attrs", {})), 181 | **self.attrs_for(table), 182 | ), 183 | ) 184 | 185 | 186 | def create_rows( 187 | attrs_list: list[TAttrs], spec: type[TAttrs], include_id_column: bool = True 188 | ) -> tuple[TableHead, *tuple[TableRow, ...]]: 189 | """Create table rows from the given attributes. 190 | 191 | Example: 192 | 193 | class PersonAttrs(Attrs): 194 | id: Annotated[int, ColumnMeta(identifier=True)] 195 | name: Annotated[str, ColumnMeta(label="Full Name")] 196 | email: Annotated[str, ColumnMeta(label="Email")] 197 | 198 | people = [ 199 | {"id": 1, "name": "John Doe", "email": "john@j.com"}, 200 | {"id": 2, "name": "Jane Smith", "email": "jane@s.com"}, 201 | ] 202 | rows = create_rows(people, spec=PersonAttrs) 203 | 204 | table = Table(*rows) 205 | 206 | Args: 207 | attrs_list (list[TAttrs]): The list of attributes to create table rows from. 208 | spec (type[TAttrs]): The specification of the attributes. 209 | 210 | Returns: 211 | tuple[TableHead, *tuple[TableRow, ...]]: list of table rows including header. 212 | """ 213 | annotations = get_type_hints(spec, include_extras=True) 214 | matadata = get_annotations_metadata_of_type(annotations, ColumnMeta) 215 | 216 | id_col_name: str = "_index" 217 | headers = [] 218 | for key, col_meta in matadata.items(): 219 | if col_meta.identifier: 220 | id_col_name = key 221 | if not col_meta.identifier or include_id_column: 222 | headers.append(col_meta.label or attr_to_camel(key)) 223 | 224 | rows: list[TableRow] = [] 225 | for idx, attrs in enumerate(attrs_list): 226 | cells: list[AnyChildren] = [] 227 | 228 | for key, value in attrs.items(): 229 | if meta := matadata.get(key): 230 | if meta.identifier and not include_id_column: 231 | continue 232 | name = f"{key}:{id_col_name}:{attrs.get(id_col_name, str(idx))}" 233 | cells.append(meta.format(name, value)) 234 | 235 | if cells: 236 | rows.append(TableRow(*cells)) 237 | 238 | return TableHead(*headers), *rows 239 | -------------------------------------------------------------------------------- /ludic/catalog/typography.py: -------------------------------------------------------------------------------- 1 | from typing import NotRequired, override 2 | 3 | try: 4 | from pygments import highlight 5 | from pygments.formatters import HtmlFormatter 6 | from pygments.lexers import get_lexer_by_name 7 | 8 | pygments_loaded = True 9 | except ImportError: 10 | pygments_loaded = False 11 | 12 | from ludic.attrs import Attrs, GlobalAttrs, HyperlinkAttrs 13 | from ludic.components import Component, ComponentStrict 14 | from ludic.html import a, code, p, pre, span, style 15 | from ludic.types import ( 16 | AnyChildren, 17 | PrimitiveChildren, 18 | Safe, 19 | ) 20 | 21 | from .utils import add_line_numbers, remove_whitespaces 22 | 23 | 24 | class LinkAttrs(Attrs): 25 | to: str 26 | external: NotRequired[bool] 27 | classes: NotRequired[list[str]] 28 | 29 | 30 | class Link(ComponentStrict[PrimitiveChildren, LinkAttrs]): 31 | """Simple component simulating a link. 32 | 33 | The main difference between :class:`Link` and :class:`a` is that this component 34 | automatically adds the ``target="_blank"`` attribute to the link if the link 35 | points to an external resource. This can be overridden by setting the 36 | ``external`` attribute to ``False``. 37 | 38 | Another difference between :class:`Link` and :class:`a` is that this component 39 | expects only one primitive child, so it cannot contain any nested elements. 40 | 41 | Example usage: 42 | 43 | Link("Hello, World!", to="https://example.com") 44 | """ 45 | 46 | @override 47 | def render(self) -> a: 48 | attrs: HyperlinkAttrs = {"href": self.attrs.get("to", "#")} 49 | if "classes" in self.attrs: 50 | attrs["classes"] = self.attrs["classes"] 51 | 52 | match self.attrs.get("external", "auto"): 53 | case True: 54 | attrs["target"] = "_blank" 55 | case False: 56 | pass 57 | case "auto": 58 | if str(attrs["href"]).startswith("http"): 59 | attrs["target"] = "_blank" 60 | 61 | return a(self.children[0], **attrs) 62 | 63 | 64 | class Paragraph(Component[AnyChildren, GlobalAttrs]): 65 | """Simple component simulating a paragraph. 66 | 67 | There is basically just an alias for the :class:`p` element. 68 | 69 | Example usage: 70 | 71 | Paragraph(f"Hello, {b("World")}!") 72 | """ 73 | 74 | @override 75 | def render(self) -> p: 76 | return p(*self.children, **self.attrs) 77 | 78 | 79 | class Code(Component[str, GlobalAttrs]): 80 | """Simple component simulating a code block. 81 | 82 | Example usage: 83 | 84 | Code("print('Hello, World!')") 85 | """ 86 | 87 | classes = ["code"] 88 | styles = style.use( 89 | lambda theme: { 90 | ".code": { 91 | "background-color": theme.colors.light, 92 | "color": theme.colors.primary.darken(1), 93 | "padding": f"{theme.sizes.xxxxs * 0.3} {theme.sizes.xxxxs}", 94 | "border-radius": theme.rounding.less, 95 | "font-size": theme.fonts.size * 0.9, 96 | } 97 | } 98 | ) 99 | 100 | @override 101 | def render(self) -> code: 102 | return code(*self.children, **self.attrs) 103 | 104 | 105 | class CodeBlockAttrs(GlobalAttrs, total=False): 106 | language: str 107 | line_numbers: bool 108 | remove_whitespaces: bool 109 | 110 | 111 | class CodeBlock(Component[str, CodeBlockAttrs]): 112 | """Simple component simulating a code block. 113 | 114 | Example usage: 115 | 116 | CodeBlock("print('Hello, World!')") 117 | """ 118 | 119 | classes = ["code-block"] 120 | styles = style.use( 121 | lambda theme: { 122 | ".code-block": { 123 | "color": theme.code.color, 124 | "border": ( 125 | f"{theme.borders.thin} solid " 126 | f"{theme.code.background_color.darken(1)}" 127 | ), 128 | "border-radius": theme.rounding.normal, 129 | "background-color": theme.code.background_color, 130 | "padding-block": theme.sizes.m, 131 | "padding-inline": theme.sizes.l, 132 | "font-size": theme.code.font_size, 133 | }, 134 | } 135 | ) 136 | 137 | def _get_line_number_span(self, line: str) -> str: 138 | return str( 139 | span( 140 | line, 141 | style={ 142 | "color": self.theme.code.line_number_color, 143 | "user-select": "none", 144 | }, 145 | ) 146 | ) 147 | 148 | @override 149 | def render(self) -> pre: 150 | content = "".join(self.children) 151 | append_line_numbers = self.attrs.get( 152 | "line_numbers", self.theme.code.line_numbers 153 | ) 154 | 155 | if self.attrs.get("remove_whitespaces", True): 156 | content = remove_whitespaces(content) 157 | 158 | if pygments_loaded and (language := self.attrs.get("language")): 159 | lexer = get_lexer_by_name(language) 160 | formatter = HtmlFormatter( 161 | noclasses=True, 162 | nobackground=True, 163 | nowrap=True, 164 | style=self.theme.code.style, 165 | ) 166 | highlighted_content = highlight(content, lexer, formatter) 167 | 168 | if append_line_numbers: 169 | highlighted_content = add_line_numbers( 170 | highlighted_content, apply_fun=self._get_line_number_span 171 | ) 172 | 173 | return pre(Safe(highlighted_content), **self.attrs_for(pre)) 174 | else: 175 | return pre(content, **self.attrs_for(pre)) 176 | -------------------------------------------------------------------------------- /ludic/catalog/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | 4 | def attr_to_camel(name: str) -> str: 5 | """Convert an attribute name to camel case. 6 | 7 | Args: 8 | name (str): The attribute name. 9 | 10 | Returns: 11 | str: The attribute name in camel case. 12 | """ 13 | return " ".join(value.title() for value in name.split("_")) 14 | 15 | 16 | def text_to_kebab(name: str) -> str: 17 | """Convert a text to kebab case. 18 | 19 | Args: 20 | name (str): The text. 21 | 22 | Returns: 23 | str: The text in kebab case. 24 | """ 25 | return "-".join(value.lower() for value in name.split()) 26 | 27 | 28 | def remove_whitespaces(text: str) -> str: 29 | """Remove leading whitespaces from a text. 30 | 31 | Args: 32 | text (str): The text. 33 | 34 | Returns: 35 | str: The text without leading whitespaces. 36 | """ 37 | if not text: 38 | return text 39 | 40 | by_line = text.splitlines() 41 | min_whitespaces = min(len(line) - len(line.lstrip()) for line in by_line if line) 42 | return "\n".join(line[min_whitespaces:].rstrip() for line in by_line).strip() 43 | 44 | 45 | def add_line_numbers(text: str, apply_fun: Callable[[str], str] = str) -> str: 46 | """Add line number at the beginning of each line. 47 | 48 | Args: 49 | text (str): Content to append line number. 50 | apply_fun (Callable[[int], str]): Call this function on each number. 51 | 52 | Returns: 53 | str: Text with number appended to each line. 54 | """ 55 | lines = text.splitlines() 56 | max_digits = len(str(len(lines))) 57 | 58 | return "\n".join( 59 | f"{apply_fun(f'{number + 1:>{max_digits}} ')}{line}" 60 | for number, line in enumerate(lines) 61 | ) 62 | -------------------------------------------------------------------------------- /ludic/components.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from collections.abc import MutableMapping, Sequence 3 | from typing import Any, ClassVar, override 4 | 5 | from .attrs import GlobalAttrs 6 | from .base import BaseElement 7 | from .elements import Blank as Blank 8 | from .elements import Element, ElementStrict 9 | from .html import div, span 10 | from .styles import Theme, get_default_theme 11 | from .styles.types import GlobalStyles 12 | from .types import AnyChildren, TAttrs, TChildren, TChildrenArgs 13 | from .utils import get_element_attrs_annotations 14 | 15 | COMPONENT_REGISTRY: MutableMapping[str, list[type["BaseComponent"]]] = {} 16 | 17 | 18 | class BaseComponent(BaseElement, metaclass=ABCMeta): 19 | classes: ClassVar[Sequence[str]] = [] 20 | styles: ClassVar[GlobalStyles] = {} 21 | 22 | @property 23 | def theme(self) -> Theme: 24 | """Get the theme of the element.""" 25 | if context_theme := self.context.get("theme"): 26 | if isinstance(context_theme, Theme): 27 | return context_theme 28 | return get_default_theme() 29 | 30 | def __init_subclass__(cls) -> None: 31 | COMPONENT_REGISTRY.setdefault(cls.__name__, []) 32 | COMPONENT_REGISTRY[cls.__name__].append(cls) 33 | 34 | def _add_classes(self, classes: list[str], element: BaseElement) -> None: 35 | if classes: 36 | element.attrs.setdefault("classes", []) # type: ignore 37 | element.attrs["classes"].extend(classes) 38 | 39 | def attrs_for(self, cls: type["BaseElement"]) -> dict[str, Any]: 40 | """Get the attributes of this component that are defined in the given element. 41 | 42 | This is useful so that you can pass common attributes to an element 43 | without having to pass them from a parent one by one. 44 | 45 | Args: 46 | cls (type[BaseElement]): The element to get the attributes of. 47 | 48 | """ 49 | return { 50 | key: value 51 | for key, value in self.attrs.items() 52 | if key in get_element_attrs_annotations(cls) 53 | } 54 | 55 | def to_html(self) -> str: 56 | dom: BaseElement | BaseComponent = self 57 | classes: list[str] = [] 58 | 59 | while isinstance(dom, BaseComponent): 60 | classes += dom.classes 61 | context = dom.context 62 | dom = dom.render() 63 | dom.context.update(context) 64 | 65 | self._add_classes(classes, dom) 66 | return dom.to_html() 67 | 68 | @abstractmethod 69 | def render(self) -> BaseElement: 70 | """Render the component as an instance of :class:`BaseElement`.""" 71 | 72 | 73 | class Component(Element[TChildren, TAttrs], BaseComponent): 74 | """Base class for components. 75 | 76 | A component subclasses an :class:`Element` and represents any element 77 | that can be rendered in Ludic. 78 | 79 | Example usage: 80 | 81 | class PersonAttrs(Attributes): 82 | age: NotRequired[int] 83 | 84 | class Person(Component[PersonAttrs]): 85 | @override 86 | def render(self) -> dl: 87 | return dl( 88 | dt("Name"), 89 | dd(self.attrs["name"]), 90 | dt("Age"), 91 | dd(self.attrs.get("age", "N/A")), 92 | ) 93 | 94 | Now the component can be used in any other component or element: 95 | 96 | >>> div(Person(name="John Doe", age=30), id="person-detail") 97 | 98 | """ 99 | 100 | 101 | class ComponentStrict(ElementStrict[*TChildrenArgs, TAttrs], BaseComponent): 102 | """Base class for strict components. 103 | 104 | A component subclasses an :class:`ElementStrict` and represents any 105 | element that can be rendered in Ludic. The difference between 106 | :class:`Component` and :class:`ComponentStrict` is that the latter 107 | expects concrete types of children. It allows specification 108 | of each child's type. 109 | 110 | Example usage: 111 | 112 | class PersonAttrs(Attributes): 113 | age: NotRequired[int] 114 | 115 | class Person(ComponentStrict[str, str, PersonAttrs]): 116 | @override 117 | def render(self) -> dl: 118 | return dl( 119 | dt("Name"), 120 | dd(" ".join(self.children), 121 | dt("Age"), 122 | dd(self.attrs.get("age", "N/A")), 123 | ) 124 | 125 | Valid usage would look like this: 126 | 127 | >>> div(Person("John", "Doe", age=30), id="person-detail") 128 | 129 | In this case, we specified that Person expects two string children. 130 | First child is the first name, and the second is the second name. 131 | We also specify age as an optional key-word argument. 132 | """ 133 | 134 | 135 | class Block(Component[AnyChildren, GlobalAttrs]): 136 | """Component rendering as a div.""" 137 | 138 | @override 139 | def render(self) -> div: 140 | return div(*self.children, **self.attrs) 141 | 142 | 143 | class Inline(Component[AnyChildren, GlobalAttrs]): 144 | """Component rendering as a span.""" 145 | 146 | @override 147 | def render(self) -> span: 148 | return span(*self.children, **self.attrs) 149 | -------------------------------------------------------------------------------- /ludic/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getludic/ludic/6f972ea35cd55fbce5a03a88077410114bf77e92/ludic/contrib/__init__.py -------------------------------------------------------------------------------- /ludic/contrib/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .middlewares import LudicMiddleware as LudicMiddleware 2 | from .responses import LudicResponse as LudicResponse 3 | -------------------------------------------------------------------------------- /ludic/contrib/django/middlewares.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | 5 | from ludic.base import BaseElement 6 | 7 | 8 | class LudicMiddleware: 9 | """Ludic middleware for Django to clean up the cache for f-strings. 10 | 11 | Usage: 12 | 13 | # somewhere in django settings.py 14 | 15 | MIDDLEWARES = [ 16 | "...", 17 | "ludic.contrib.django.LudicMiddleware", 18 | ] 19 | """ 20 | 21 | def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: 22 | self.get_response = get_response 23 | 24 | def __call__(self, request: HttpRequest) -> HttpResponse: 25 | with BaseElement.formatter: 26 | response: HttpResponse = self.get_response(request) 27 | 28 | return response 29 | -------------------------------------------------------------------------------- /ludic/contrib/django/responses.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.http import HttpResponse 4 | 5 | from ludic.types import AnyChildren 6 | 7 | 8 | class LudicResponse(HttpResponse): 9 | """Class representing Ludic response for Django View. 10 | 11 | Usage: 12 | 13 | from django.http import HttpRequest 14 | 15 | from ludic.html import p 16 | from ludic.contrib.django import LudicResponse 17 | 18 | def index(request: HttpRequest) -> LudicResponse: 19 | return LudicResponse(p("Hello, World!")) 20 | 21 | """ 22 | 23 | def __init__(self, content: AnyChildren = "", *args: Any, **kwargs: Any) -> None: 24 | super().__init__(str(content), *args, **kwargs) 25 | -------------------------------------------------------------------------------- /ludic/contrib/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | from ludic.web import LudicResponse 2 | 3 | from .routing import LudicRoute 4 | 5 | __all__ = ("LudicRoute", "LudicResponse") 6 | -------------------------------------------------------------------------------- /ludic/contrib/fastapi/routing.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | from collections.abc import Callable, Coroutine 4 | from typing import Any, ParamSpec, get_args, get_origin 5 | 6 | from fastapi import Request 7 | from fastapi._compat import lenient_issubclass 8 | from fastapi.datastructures import Default, DefaultPlaceholder 9 | from fastapi.dependencies.utils import get_typed_return_annotation 10 | from fastapi.routing import APIRoute 11 | from starlette._utils import is_async_callable 12 | from starlette.responses import Response 13 | from starlette.routing import get_name 14 | 15 | from ludic.base import BaseElement 16 | from ludic.web.requests import Request as LudicRequest 17 | from ludic.web.responses import LudicResponse, run_in_threadpool_safe 18 | 19 | P = ParamSpec("P") 20 | 21 | 22 | def is_base_element(type_: type[Any]) -> bool: 23 | """Check if type is ludic's BaseElement""" 24 | if get_args(type_) and lenient_issubclass(get_origin(type_), BaseElement): 25 | return True 26 | return lenient_issubclass(type_, BaseElement) 27 | 28 | 29 | def function_wrapper( 30 | handler: Callable[..., Any], status_code: int = 200 31 | ) -> Callable[P, Any]: 32 | """Wraps endpoints to ensure responses are formatted as LudicResponse objects. 33 | 34 | This function determines whether the handler is asynchronous or synchronous and 35 | executes it accordingly. If the handler returns a BaseElement instance, it wraps 36 | the response in a LudicResponse object. 37 | 38 | Args: 39 | handler (Callable[..., Any]): The FastAPI endpoint handler function. 40 | status_code (int, optional): The HTTP status code for the response. 41 | Defaults to 200. 42 | 43 | Returns: 44 | Callable[P, Any]: A wrapped function that ensures proper response handling. 45 | """ 46 | 47 | @functools.wraps(handler) 48 | async def wrapped_endpoint(*args: P.args, **kwargs: P.kwargs) -> Any: 49 | if is_async_callable(handler): 50 | with BaseElement.formatter: 51 | raw_response = await handler(*args, **kwargs) 52 | else: 53 | raw_response = await run_in_threadpool_safe(handler, *args, **kwargs) 54 | 55 | if isinstance(raw_response, BaseElement): 56 | return LudicResponse(raw_response, status_code=status_code) 57 | return raw_response 58 | 59 | return wrapped_endpoint 60 | 61 | 62 | class LudicRoute(APIRoute): 63 | """Custom Route class for FastAPI that integrates Ludic framework response handling. 64 | 65 | This class ensures that endpoints returning `BaseElement` instances are properly 66 | wrapped in `LudicResponse`. If a response model is not explicitly provided, it 67 | infers the return type annotation from the endpoint function. 68 | 69 | Args: 70 | path (str): The API route path. 71 | endpoint (Callable[..., Any]): The FastAPI endpoint function. 72 | response_model (Any, optional): The response model for OpenAPI documentation. 73 | Defaults to None. 74 | name (str | None, optional): The route name. Defaults to None. 75 | status_code (int | None, optional): The HTTP status code. Defaults to None. 76 | **kwargs (Any): Additional parameters for APIRoute. 77 | """ 78 | 79 | def __init__( 80 | self, 81 | path: str, 82 | endpoint: Callable[..., Any], 83 | *, 84 | response_model: Any = Default(None), # noqa 85 | name: str | None = None, 86 | status_code: int | None = None, 87 | **kwargs: Any, 88 | ) -> None: 89 | if isinstance(response_model, DefaultPlaceholder): 90 | return_annotation = get_typed_return_annotation(endpoint) 91 | if is_base_element(return_annotation) or lenient_issubclass( 92 | return_annotation, Response 93 | ): 94 | response_model = None 95 | else: 96 | response_model = return_annotation 97 | 98 | name = get_name(endpoint) if name is None else name 99 | wrapped_route = endpoint 100 | 101 | if inspect.isfunction(endpoint) or inspect.ismethod(endpoint): 102 | wrapped_route = function_wrapper(endpoint, status_code=status_code or 200) 103 | if getattr(endpoint, "route", None) is None: 104 | endpoint.route = self # type: ignore 105 | 106 | super().__init__( 107 | path, wrapped_route, response_model=response_model, name=name, **kwargs 108 | ) 109 | 110 | def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: 111 | original_route_handler = super().get_route_handler() 112 | 113 | async def custom_route_handler(request: Request) -> Response: 114 | request = LudicRequest(request.scope, request.receive) 115 | return await original_route_handler(request) 116 | 117 | return custom_route_handler 118 | -------------------------------------------------------------------------------- /ludic/elements.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Generic, Unpack 2 | 3 | from .attrs import NoAttrs 4 | from .base import BaseElement 5 | from .types import TAttrs, TChildren, TChildrenArgs 6 | 7 | 8 | class Element(Generic[TChildren, TAttrs], BaseElement): 9 | """Base class for Ludic elements. 10 | 11 | Args: 12 | *children (TChild): The children of the element. 13 | **attrs (Unpack[TAttrs]): The attributes of the element. 14 | """ 15 | 16 | children: tuple[TChildren, ...] 17 | attrs: TAttrs 18 | 19 | formatter_wrap_in: ClassVar[type[BaseElement] | None] = None 20 | 21 | def __init__( 22 | self, 23 | *children: TChildren, 24 | # FIXME: https://github.com/python/typing/issues/1399 25 | **attrs: Unpack[TAttrs], # type: ignore 26 | ) -> None: 27 | super().__init__(*children, **attrs) 28 | 29 | 30 | class ElementStrict(Generic[*TChildrenArgs, TAttrs], BaseElement): 31 | """Base class for strict elements (elements with concrete types of children). 32 | 33 | Args: 34 | *children (*TChildTuple): The children of the element. 35 | **attrs (Unpack[TAttrs]): The attributes of the element. 36 | """ 37 | 38 | children: tuple[*TChildrenArgs] 39 | attrs: TAttrs 40 | 41 | formatter_wrap_in: ClassVar[type[BaseElement] | None] = None 42 | 43 | def __init__( 44 | self, 45 | *children: *TChildrenArgs, 46 | # FIXME: https://github.com/python/typing/issues/1399 47 | **attrs: Unpack[TAttrs], # type: ignore 48 | ) -> None: 49 | super().__init__(*children, **attrs) 50 | 51 | 52 | class Blank(Element[TChildren, NoAttrs]): 53 | """Element representing no element at all, just children. 54 | 55 | The purpose of this element is to be able to return only children 56 | when rendering a component. 57 | """ 58 | 59 | def __init__(self, *children: TChildren) -> None: 60 | super().__init__(*children) 61 | 62 | def to_html(self) -> str: 63 | return "".join(map(str, self.children)) 64 | -------------------------------------------------------------------------------- /ludic/format.py: -------------------------------------------------------------------------------- 1 | import html 2 | import inspect 3 | import random 4 | import re 5 | from collections.abc import Mapping 6 | from contextvars import ContextVar 7 | from functools import lru_cache 8 | from typing import Any, Final, TypeVar, get_type_hints 9 | 10 | T = TypeVar("T") 11 | 12 | _EXTRACT_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"\{(\d+:id)\}") 13 | 14 | 15 | @lru_cache 16 | def _load_attrs_aliases() -> Mapping[str, str]: 17 | from ludic import attrs 18 | 19 | result = {} 20 | for name, cls in inspect.getmembers(attrs, inspect.isclass): 21 | if not name.endswith("Attrs"): 22 | continue 23 | 24 | hints = get_type_hints(cls, include_extras=True) 25 | for key, value in hints.items(): 26 | if metadata := getattr(value, "__metadata__", None): 27 | for meta in metadata: 28 | if isinstance(meta, attrs.Alias): 29 | result[key] = str(meta) 30 | 31 | return result 32 | 33 | 34 | def format_attr_value(key: str, value: Any, is_html: bool = False) -> str: 35 | """Format an HTML attribute with the given key and value. 36 | 37 | Args: 38 | key (str): The key of the attribute. 39 | value (Any): The value of the attribute, can be a string or a dictionary. 40 | Returns: 41 | str: The formatted HTML attribute. 42 | """ 43 | 44 | def _html_escape(value: Any) -> str: 45 | if ( 46 | is_html 47 | and value 48 | and isinstance(value, str) 49 | and getattr(value, "escape", True) 50 | ): 51 | return html.escape(value, False) # type: ignore 52 | return str(value) 53 | 54 | if isinstance(value, dict): 55 | formatted_value = ";".join( 56 | f"{dict_key}:{_html_escape(dict_value)}" 57 | for dict_key, dict_value in value.items() 58 | ) 59 | elif isinstance(value, list): 60 | formatted_value = " ".join(map(_html_escape, value)) 61 | elif isinstance(value, bool): 62 | if is_html and not key.startswith("hx"): 63 | formatted_value = html.escape(key, False) if value else "" 64 | else: 65 | formatted_value = "true" if value else "false" 66 | else: 67 | formatted_value = _html_escape(value) 68 | 69 | return formatted_value 70 | 71 | 72 | def format_attrs(attrs: Mapping[str, Any], is_html: bool = False) -> dict[str, Any]: 73 | """Format the given attributes. 74 | 75 | attrs = {"name": "John", "class_": "person", "is_adult": True} 76 | 77 | The result will be: 78 | 79 | >>> format_attrs(attrs) 80 | >>> {"name": "John", "class": "person"} 81 | 82 | Args: 83 | attrs (dict[str, Any]): The attributes to format. 84 | 85 | Returns: 86 | dict[str, Any]: The formatted attributes. 87 | """ 88 | aliases = _load_attrs_aliases() 89 | result: dict[str, str] = {} 90 | 91 | for key, value in attrs.items(): 92 | if formatted_value := format_attr_value(key, value, is_html=is_html): 93 | if key in aliases: 94 | alias = aliases[key] 95 | else: 96 | alias = key.strip("_").replace("_", "-") 97 | 98 | if alias in result: 99 | result[alias] += " " + formatted_value 100 | else: 101 | result[alias] = formatted_value 102 | return result 103 | 104 | 105 | def format_element(child: Any) -> str: 106 | """Default HTML formatter. 107 | 108 | Args: 109 | child (AnyChild): The HTML element or text to format. 110 | """ 111 | if isinstance(child, str) and getattr(child, "escape", True): 112 | return html.escape(child, False) 113 | elif hasattr(child, "to_html"): 114 | return child.to_html() # type: ignore 115 | else: 116 | return str(child) 117 | 118 | 119 | def _extract_match(text: str | Any) -> int | str: 120 | if text.endswith(":id") and text[:-3].isdigit(): 121 | return int(text[:-3]) 122 | return str(text) 123 | 124 | 125 | def extract_identifiers(text: str) -> list[str | int]: 126 | """Extract numbers from a string. 127 | 128 | Args: 129 | text (str): The string to extract numbers from. 130 | 131 | Returns: 132 | Iterable[int]: The extracted numbers. 133 | """ 134 | parts = [_extract_match(match) for match in _EXTRACT_NUMBER_RE.split(text) if match] 135 | if any(isinstance(part, int) for part in parts): 136 | return parts 137 | else: 138 | return [] 139 | 140 | 141 | class FormatContext: 142 | """Format context helper for using f-strings in elements. 143 | 144 | Facilitates dynamic content formatting within Ludic elements using f-strings. 145 | 146 | This class addresses potential memory leaks when using f-strings directly in 147 | element generation. Employing the 'with' statement ensures proper cleanup of 148 | formatting context. 149 | 150 | It is possible to use f-strings in elements without the contextmanager, 151 | however, the contextmanager clears the context (cache) at the end of the block. 152 | So without this manager, you can have memory leaks in your app. 153 | 154 | It is recommended to wrap, for example, request context with this manager. 155 | 156 | Example usage: 157 | 158 | with FormatContext(): 159 | dom = div(f"test {b('foo')} {i('bar')}") 160 | 161 | assert dom.to_html() == "
test foo bar
" 162 | """ 163 | 164 | _context: ContextVar[dict[int, Any]] 165 | 166 | def __init__(self, name: str) -> None: 167 | self._context = ContextVar(name) 168 | 169 | def get(self) -> dict[int, Any]: 170 | try: 171 | return self._context.get() 172 | except LookupError: 173 | return {} 174 | 175 | def append(self, obj: Any) -> str: 176 | """Store the given object in context memory and return the identifier. 177 | 178 | Args: 179 | obj (Any): The object to store in context memory. 180 | 181 | Returns: 182 | str: The identifier of the stored object. 183 | """ 184 | random_id = random.getrandbits(256) 185 | 186 | try: 187 | cache = self._context.get() 188 | cache[random_id] = obj 189 | self._context.set(cache) 190 | except LookupError: 191 | self._context.set({random_id: obj}) 192 | 193 | return f"{{{random_id}:id}}" 194 | 195 | def extract(self, *args: Any, WrapIn: type | None = None) -> tuple[Any, ...]: 196 | """Extract identifiers from the given arguments. 197 | 198 | Example: 199 | 200 | with FormatContext() as ctx: 201 | first = ctx.append("foo") 202 | second = ctx.append({"bar": "baz"}) 203 | print(ctx.extract(f"test {first} {second}")) 204 | 205 | ["test ", "foo", " ", {"bar": "baz"}] 206 | 207 | Args: 208 | WrapIn 209 | args (Any): The arguments to extract identifiers from. 210 | 211 | Returns: 212 | Any: The extracted arguments. 213 | """ 214 | arguments: list[Any] = [] 215 | for arg in args: 216 | if isinstance(arg, str) and (parts := extract_identifiers(arg)): 217 | cache = self.get() 218 | extracted_args = ( 219 | cache.pop(part) if isinstance(part, int) else part 220 | for part in parts 221 | if not isinstance(part, int) or part in cache 222 | ) 223 | if WrapIn is not None: 224 | arguments.append(WrapIn(*extracted_args)) 225 | else: 226 | arguments.extend(extracted_args) 227 | self._context.set(cache) 228 | else: 229 | arguments.append(arg) 230 | return tuple(arguments) 231 | 232 | def clear(self) -> None: 233 | """Clear the context memory.""" 234 | self._context.set({}) 235 | 236 | def __enter__(self) -> "FormatContext": 237 | return self 238 | 239 | def __exit__(self, *_: Any) -> None: 240 | self.clear() 241 | -------------------------------------------------------------------------------- /ludic/mypy_plugin.py: -------------------------------------------------------------------------------- 1 | """This module is designed specifically for use with the mypy plugin.""" 2 | 3 | from collections.abc import Callable 4 | from typing import TypeVar 5 | 6 | from mypy.nodes import ARG_STAR, ARG_STAR2, Argument, TypeInfo, Var 7 | from mypy.plugin import ClassDefContext, Plugin 8 | from mypy.plugins.common import add_method 9 | from mypy.types import ( 10 | AnyType, 11 | NoneTyp, 12 | TupleType, 13 | TypeOfAny, 14 | TypeVarType, 15 | UnpackType, 16 | ) 17 | 18 | T = TypeVar("T") 19 | CB = Callable[[T], None] | None 20 | 21 | BLACKLISTED_ELEMENTS = { 22 | "ludic.components.Component", 23 | "ludic.components.ComponentStrict", 24 | "ludic.web.endpoints.Endpoint", 25 | } 26 | 27 | 28 | def is_component_base(info: TypeInfo) -> bool: 29 | """Check if this is a subclass of a Ludic element.""" 30 | return info.fullname in ( 31 | "ludic.base.Element", 32 | "ludic.base.ElementStrict", 33 | "ludic.components.Component", 34 | "ludic.components.ComponentStrict", 35 | ) 36 | 37 | 38 | class LudicPlugin(Plugin): 39 | def get_base_class_hook(self, fullname: str) -> "CB[ClassDefContext]": 40 | sym = self.lookup_fully_qualified(fullname) 41 | if sym and isinstance(sym.node, TypeInfo): 42 | if is_component_base(sym.node): 43 | return add_init_hook 44 | return None 45 | 46 | 47 | def add_init_hook(ctx: ClassDefContext) -> None: 48 | """Add a dummy __init__() to a model and record it is generated. 49 | 50 | Instantiation will be checked more precisely when we inferred types 51 | (using get_function_hook and model_hook). 52 | """ 53 | node = ctx.cls.info 54 | 55 | if "__init__" in node.names or node.fullname in BLACKLISTED_ELEMENTS: 56 | # Don't override existing definition. 57 | return 58 | 59 | for base in node.bases: 60 | if is_component_base(base.type) and any( 61 | not isinstance(arg, TypeVarType) for arg in base.args 62 | ): 63 | break 64 | else: 65 | return 66 | 67 | match base.type.name: 68 | case "Element" | "Component": 69 | args_type = base.args[0] 70 | case "ElementStrict" | "ComponentStrict": 71 | args_type = UnpackType( 72 | TupleType( 73 | list(base.args[:-1]), 74 | ctx.api.builtin_type("builtins.tuple"), 75 | ) 76 | ) 77 | case _: 78 | return 79 | 80 | args_var = Var("args", args_type) 81 | args_arg = Argument( 82 | variable=args_var, 83 | type_annotation=args_type, 84 | initializer=None, 85 | kind=ARG_STAR, 86 | ) 87 | 88 | kwargs_type = ( 89 | AnyType(TypeOfAny.special_form) 90 | if isinstance(base.args[-1], TypeVarType) 91 | else UnpackType(base.args[-1]) 92 | ) 93 | kwargs_var = Var("kwargs", kwargs_type) 94 | kwargs_arg = Argument( 95 | variable=kwargs_var, 96 | type_annotation=kwargs_type, 97 | initializer=None, 98 | kind=ARG_STAR2, 99 | ) 100 | 101 | add_method(ctx, "__init__", [args_arg, kwargs_arg], NoneTyp()) 102 | ctx.cls.info.metadata.setdefault("ludic", {})["generated_init"] = True 103 | 104 | 105 | def plugin(version: str) -> type[LudicPlugin]: 106 | return LudicPlugin 107 | -------------------------------------------------------------------------------- /ludic/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getludic/ludic/6f972ea35cd55fbce5a03a88077410114bf77e92/ludic/py.typed -------------------------------------------------------------------------------- /ludic/styles/__init__.py: -------------------------------------------------------------------------------- 1 | from .collect import format_styles, from_components, from_loaded 2 | from .themes import Theme, get_default_theme, set_default_theme 3 | from .types import CSSProperties, GlobalStyles 4 | 5 | __all__ = ( 6 | "CSSProperties", 7 | "GlobalStyles", 8 | "Theme", 9 | "format_styles", 10 | "from_components", 11 | "from_loaded", 12 | "get_default_theme", 13 | "set_default_theme", 14 | ) 15 | -------------------------------------------------------------------------------- /ludic/styles/collect.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping, MutableMapping 2 | from typing import Any 3 | 4 | from ludic.base import BaseElement 5 | 6 | from .themes import Theme, get_default_theme 7 | from .types import CSSProperties, GlobalStyles 8 | 9 | GLOBAL_STYLES_CACHE: MutableMapping[str, GlobalStyles] = {} 10 | 11 | 12 | def format_styles(styles: GlobalStyles, separator: str = "\n") -> str: 13 | """Format styles from all registered elements. 14 | 15 | Args: 16 | styles (GlobalStyles): Styles to format. 17 | """ 18 | result: list[str] = [] 19 | nodes_to_parse: list[ 20 | tuple[list[str], str | Mapping[str | tuple[str, ...], Any]] 21 | ] = [([], styles)] 22 | 23 | while nodes_to_parse: 24 | parents, node = nodes_to_parse.pop(0) 25 | 26 | content = [] 27 | if isinstance(node, str): 28 | content.append(node) 29 | else: 30 | for key, value in node.items(): 31 | if isinstance(value, str | int | float): 32 | content.append(f"{key}: {value};") 33 | elif isinstance(value, Mapping): 34 | keys = (key,) if isinstance(key, str | int | float) else key 35 | for key in keys: 36 | if key.startswith("@"): 37 | value = format_styles(value, separator=" ") 38 | nodes_to_parse.append(([*parents, key], value)) 39 | 40 | if content: 41 | result.append(f"{" ".join(parents)} {{ {" ".join(content)} }}") 42 | 43 | return separator.join(result) 44 | 45 | 46 | def from_components( 47 | *components: type["BaseElement"], theme: Theme | None = None 48 | ) -> GlobalStyles: 49 | """Global styles collector from given components. 50 | 51 | Example usage: 52 | 53 | class Page(Component[AnyChildren, NoAttrs]): 54 | 55 | @override 56 | def render(self) -> html: 57 | return html( 58 | head( 59 | title("An example Example"), 60 | styles(collect_from_components()), 61 | ), 62 | body( 63 | *self.children, 64 | ), 65 | ) 66 | 67 | This would render an HTML page containing the ``" 50 | ) 51 | 52 | 53 | def test_styles_nested_formatting() -> None: 54 | assert ( 55 | style( 56 | { 57 | "p.message": { 58 | "color": "black", # type: ignore[dict-item] 59 | "background": "yellow", # type: ignore[dict-item] 60 | "padding": "10px", # type: ignore[dict-item] 61 | "a": { 62 | "color": "red", 63 | "text-decoration": "none", 64 | }, 65 | "a:hover": { 66 | "text-decoration": "underline", 67 | }, 68 | }, 69 | } 70 | ).to_html() 71 | == ( 72 | "" 77 | ) 78 | ) 79 | 80 | 81 | def test_styles_with_at_rule() -> None: 82 | assert ( 83 | style( 84 | { 85 | ".htmx-settling": { 86 | "opacity": "100", 87 | }, 88 | "@keyframes lds-ellipsis1": { 89 | "0%": { 90 | "transform": "scale(0)", 91 | "color": "red", 92 | } 93 | }, 94 | "@layer state": { 95 | ".alert": { 96 | "background-color": "brown", 97 | }, 98 | "p": { 99 | "padding": "10px", 100 | }, 101 | }, 102 | } 103 | ).to_html() 104 | ) == ( 105 | "" 110 | ) 111 | 112 | 113 | def test_styles_collection() -> None: 114 | assert style.from_components(A, B, theme=FooTheme()).to_html() == ( 115 | '" 121 | ) 122 | -------------------------------------------------------------------------------- /tests/styles/test_themes.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import GlobalAttrs 4 | from ludic.components import Component 5 | from ludic.html import a, b, div, style 6 | from ludic.styles.themes import ( 7 | Colors, 8 | Fonts, 9 | Sizes, 10 | get_default_theme, 11 | set_default_theme, 12 | ) 13 | from ludic.styles.types import Color, Size, SizeClamp 14 | 15 | from . import BarTheme, FooTheme 16 | 17 | 18 | def test_theme_colors() -> None: 19 | theme = FooTheme( 20 | colors=Colors( 21 | primary=Color("#c2e7fd"), 22 | white=Color("#fff"), 23 | light=Color("#eee"), 24 | dark=Color("#333"), 25 | black=Color("#000"), 26 | ) 27 | ) 28 | 29 | assert theme.colors.primary.rgb == (194, 231, 253) 30 | assert theme.colors.white.rgb == (255, 255, 255) 31 | assert theme.colors.light.rgb == (238, 238, 238) 32 | assert theme.colors.dark.rgb == (51, 51, 51) 33 | assert theme.colors.black.rgb == (0, 0, 0) 34 | 35 | assert theme.colors.white.darken(10).rgb == (127, 127, 127) 36 | assert theme.colors.white.darken(20).rgb == (1, 1, 1) 37 | assert theme.colors.black.lighten(10).rgb == (127, 127, 127) 38 | assert theme.colors.black.lighten(20).rgb == (255, 255, 255) 39 | 40 | assert theme.colors.light.darken(10).rgb == (119, 119, 119) 41 | assert theme.colors.dark.lighten(10).rgb == (153, 153, 153) 42 | 43 | assert theme.colors.primary.darken(10).rgb == (97, 115, 126) 44 | 45 | 46 | def test_theme_font_sizes() -> None: 47 | theme = FooTheme(fonts=Fonts(size=Size(10, "px"))) 48 | 49 | assert theme.fonts.size == "10px" 50 | assert theme.fonts.primary == "Helvetica Neue, Helvetica, Arial, sans-serif" 51 | 52 | assert theme.fonts.size - 1 == "9px" 53 | assert theme.fonts.size + 5 == "15px" 54 | assert theme.fonts.size * 2 == "20px" 55 | 56 | assert theme.fonts.size + "2" == "10px" 57 | assert theme.fonts.size - "ab" == "10px" 58 | 59 | 60 | def test_theme_sizes() -> None: 61 | theme = FooTheme(sizes=Sizes(m=SizeClamp(1, 1.2, 3))) 62 | 63 | assert theme.sizes.m == "clamp(1rem, 1rem + 1.2vw, 3rem)" 64 | 65 | assert theme.sizes.m - 0.1 == "clamp(0.9rem, 0.9rem + 1.1vw, 2.9rem)" 66 | assert theme.sizes.m + 0.5 == "clamp(1.5rem, 1.5rem + 1.7vw, 3.5rem)" 67 | assert theme.sizes.m * 0.2 == "clamp(0.2rem, 0.2rem + 0.24vw, 0.6rem)" 68 | 69 | assert theme.sizes.m + "2" == "clamp(1rem, 1rem + 1.2vw, 3rem)" 70 | assert theme.sizes.m - "ab" == "clamp(1rem, 1rem + 1.2vw, 3rem)" 71 | 72 | 73 | def test_themes_switching() -> None: 74 | foo, bar = FooTheme(), BarTheme() 75 | 76 | set_default_theme(foo) 77 | assert get_default_theme() == foo 78 | set_default_theme(bar) 79 | assert get_default_theme() == bar 80 | 81 | 82 | def test_element_theme_switching() -> None: 83 | foo = FooTheme() 84 | bar = BarTheme() 85 | 86 | set_default_theme(bar) 87 | 88 | class C1(Component[str, GlobalAttrs]): # type: ignore 89 | styles = style.use( 90 | lambda theme: { 91 | "#c1 a": {"color": theme.colors.warning}, 92 | } 93 | ) 94 | 95 | @override 96 | def render(self) -> div: 97 | return div( 98 | b("Hello, ", style={"color": self.theme.colors.secondary}), 99 | a(*self.children, href="https://example.com"), 100 | id="c1", 101 | ) 102 | 103 | class C2(Component[str, GlobalAttrs]): # type: ignore 104 | styles = style.use( 105 | lambda theme: { 106 | "#c2 a": {"color": theme.colors.danger}, 107 | } 108 | ) 109 | 110 | @override 111 | def render(self) -> div: 112 | return div( 113 | foo.use(C1(*self.children)), 114 | id="c2", 115 | style={"background-color": self.theme.colors.primary}, 116 | ) 117 | 118 | assert C2("World").to_html() == ( 119 | f'
' 120 | '
' 121 | f'Hello, ' 122 | 'World' 123 | "
" 124 | "
" 125 | ) # fmt: skip 126 | assert style.from_components(C1, C2).to_html() == ( 127 | '" 131 | ) # fmt: skip 132 | 133 | set_default_theme(foo) 134 | 135 | assert style.from_components(C1, C2).to_html() == ( 136 | '" 140 | ) # fmt: skip 141 | -------------------------------------------------------------------------------- /tests/test_components.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | from ludic.attrs import Attrs 4 | from ludic.components import Component 5 | from ludic.html import div 6 | from ludic.types import AnyChildren 7 | 8 | 9 | class ClassesComponentAttrs(Attrs): 10 | class_: str 11 | 12 | 13 | class ClassesComponent(Component[AnyChildren, ClassesComponentAttrs]): 14 | classes = ["class-a"] 15 | 16 | @override 17 | def render(self) -> div: 18 | return div(*self.children, classes=["class-b"], **self.attrs) 19 | 20 | 21 | def test_component_classes() -> None: 22 | assert ClassesComponent( 23 | div("content", class_="class-f"), class_="class-c class-d" 24 | ).to_html() == ( 25 | '
' 26 | '
content
' 27 | '
' 28 | ) # fmt: skip 29 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | from starlette.testclient import TestClient 2 | 3 | 4 | def test_bulk_update() -> None: 5 | from examples.bulk_update import app, db 6 | 7 | with TestClient(app) as client: 8 | assert client.get("/").status_code == 200 9 | assert client.get("/people/").status_code == 200 10 | assert db.people["1"].active 11 | assert db.people["2"].active 12 | assert db.people["3"].active 13 | assert not db.people["4"].active 14 | 15 | activate_data = {"active:id:1": "on", "active:id:2": "on"} 16 | assert client.post("/people/", data=activate_data).status_code == 200 17 | assert db.people["1"].active 18 | assert db.people["2"].active 19 | assert not db.people["3"].active 20 | assert not db.people["4"].active 21 | 22 | 23 | def test_click_to_edit() -> None: 24 | from examples.click_to_edit import app, db 25 | 26 | with TestClient(app) as client: 27 | assert client.get("/").status_code == 200 28 | assert client.get("/contacts/1").status_code == 200 29 | assert client.get("/contacts/1/form/").status_code == 200 30 | assert client.get("/contacts/123").status_code == 404 31 | assert client.get("/contacts/123/form/").status_code == 404 32 | assert db.contacts["1"].first_name == "John" 33 | 34 | edit_data = { 35 | "first_name": "Test", 36 | "last_name": "Doe", 37 | "email": "test@example.com", 38 | } 39 | assert client.put("/contacts/1", data=edit_data).status_code == 200 40 | assert db.contacts["1"].first_name == "Test" 41 | 42 | 43 | def test_click_to_load() -> None: 44 | from examples.click_to_load import app 45 | 46 | with TestClient(app) as client: 47 | assert client.get("/").status_code == 200 48 | assert client.get("/contacts/").status_code == 200 49 | assert client.get("/contacts/?page=2").status_code == 200 50 | 51 | 52 | def test_delete_row() -> None: 53 | from examples.delete_row import app, db 54 | 55 | with TestClient(app) as client: 56 | assert client.get("/").status_code == 200 57 | assert client.get("/people/").status_code == 200 58 | assert client.delete("/people/1").status_code == 204 59 | assert client.delete("/people/123").status_code == 404 60 | assert db.people.get("1") is None 61 | 62 | 63 | def test_edit_row() -> None: 64 | from examples.edit_row import app, db 65 | 66 | with TestClient(app) as client: 67 | assert client.get("/").status_code == 200 68 | assert client.get("/people/").status_code == 200 69 | assert client.get("/people/1").status_code == 200 70 | assert client.get("/people/1/form/").status_code == 200 71 | assert client.get("/people/123").status_code == 404 72 | assert client.get("/people/123/form/").status_code == 404 73 | 74 | assert db.people["1"].name == "Joe Smith" 75 | edit_data = {"name": "Test", "email": "test@example.com"} 76 | assert client.put("/people/1", data=edit_data).status_code == 200 77 | assert db.people["1"].name == "Test" 78 | assert db.people["1"].email == "test@example.com" 79 | 80 | 81 | def test_lazy_loading() -> None: 82 | from examples.lazy_loading import app 83 | 84 | with TestClient(app) as client: 85 | assert client.get("/").status_code == 200 86 | response = client.get("/load/0") 87 | assert response.status_code == 200 88 | assert b"Content Loaded" in response.content 89 | 90 | 91 | def test_infinite_scroll() -> None: 92 | from examples.infinite_scroll import app 93 | 94 | with TestClient(app) as client: 95 | assert client.get("/").status_code == 200 96 | assert client.get("/contacts/").status_code == 200 97 | assert client.get("/contacts/?page=2").status_code == 200 98 | 99 | 100 | def test_fastapi_example() -> None: 101 | from examples.fastapi_example import app 102 | 103 | with TestClient(app) as client: 104 | assert client.get("/").status_code == 200 105 | assert client.get("/cars/").status_code == 200 106 | assert client.get("/models/?manufacturer=audi").status_code == 200 107 | assert client.get("/models/").status_code == 404 108 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.testclient import TestClient 3 | 4 | from ludic.html import p 5 | from ludic.web import LudicApp, Request 6 | from ludic.web.exceptions import ( 7 | BadGatewayError, 8 | BadRequestError, 9 | ClientError, 10 | ForbiddenError, 11 | GatewayTimeoutError, 12 | InternalServerError, 13 | MethodNotAllowedError, 14 | NotFoundError, 15 | NotImplementedError, 16 | PaymentRequiredError, 17 | ServerError, 18 | ServiceUnavailableError, 19 | TooManyRequestsError, 20 | UnauthorizedError, 21 | ) 22 | 23 | app = LudicApp() 24 | 25 | 26 | @app.get("/") 27 | async def index() -> p: 28 | return p("hello world") 29 | 30 | 31 | @app.get("/error") 32 | async def error() -> p: 33 | raise RuntimeError("error happened") 34 | 35 | 36 | @app.post("/only-post") 37 | async def only_post() -> p: 38 | return p("only post") 39 | 40 | 41 | @app.get("/not-found") 42 | async def page_not_found() -> p: 43 | raise NotFoundError("This page does not exist.") 44 | 45 | 46 | @app.exception_handler(404) 47 | async def not_found(request: Request) -> p: 48 | return p(f"page {request.url.path} does not exist") 49 | 50 | 51 | @app.exception_handler(405) 52 | async def method_not_allowed() -> p: 53 | return p("method not allowed") 54 | 55 | 56 | @app.exception_handler(500) 57 | async def server_error(exception: Exception) -> p: 58 | return p(f"server error: {exception}") 59 | 60 | 61 | def test_exception_handling() -> None: 62 | test_data = [ 63 | ("/", 200, "hello world"), 64 | ("/not-found", 404, "page /not-found does not exist"), 65 | ("/only-post", 405, "method not allowed"), 66 | ("/error", 500, "server error: error happened"), 67 | ] 68 | 69 | with TestClient(app, raise_server_exceptions=False) as client: 70 | for path, status, content in test_data: 71 | response = client.get(path) 72 | assert response.status_code == status 73 | assert response.text == p(content).to_html() 74 | 75 | 76 | def test_exceptions() -> None: 77 | test_data = [ 78 | (ClientError, 400), 79 | (BadRequestError, 400), 80 | (UnauthorizedError, 401), 81 | (PaymentRequiredError, 402), 82 | (ForbiddenError, 403), 83 | (NotFoundError, 404), 84 | (MethodNotAllowedError, 405), 85 | (TooManyRequestsError, 429), 86 | (ServerError, 500), 87 | (InternalServerError, 500), 88 | (NotImplementedError, 501), 89 | (BadGatewayError, 502), 90 | (ServiceUnavailableError, 503), 91 | (GatewayTimeoutError, 504), 92 | ] 93 | 94 | for Error, status_code in test_data: 95 | assert issubclass(Error, Exception) 96 | with pytest.raises(Error) as error: 97 | raise Error("test message") 98 | 99 | assert ( 100 | error.exconly() 101 | == f"ludic.web.exceptions.{Error.__name__}: {status_code}: test message" 102 | ) 103 | -------------------------------------------------------------------------------- /tests/test_formatting.py: -------------------------------------------------------------------------------- 1 | from ludic.base import BaseElement 2 | from ludic.catalog.typography import Link, Paragraph 3 | from ludic.format import FormatContext, format_attr_value, format_attrs 4 | from ludic.html import b, div, i, p, strong 5 | 6 | 7 | def test_format_attr_value() -> None: 8 | assert format_attr_value("foo", "bar") == "bar" 9 | assert format_attr_value("foo", "bar", is_html=True) == "bar" 10 | 11 | assert format_attr_value("foo", 1) == "1" 12 | assert format_attr_value("foo", 1, is_html=True) == "1" 13 | 14 | assert format_attr_value("foo", True) == "true" 15 | assert format_attr_value("foo", True, is_html=True) == "foo" 16 | 17 | assert format_attr_value("foo", False) == "false" 18 | assert format_attr_value("foo", False, is_html=True) == "" 19 | 20 | assert format_attr_value("hx-confirm", True) == "true" 21 | assert format_attr_value("hx-confirm", True, is_html=True) == "true" 22 | 23 | assert format_attr_value("hx-confirm", False) == "false" 24 | assert format_attr_value("hx-confirm", False, is_html=True) == "false" 25 | 26 | assert ( 27 | format_attr_value("style", {"color": "red", "background": "blue"}) 28 | == "color:red;background:blue" 29 | ) 30 | 31 | 32 | def test_format_context() -> None: 33 | with FormatContext("test_context") as ctx: 34 | first = ctx.append("foo") 35 | second = ctx.append({"bar": "baz"}) 36 | extracts = ctx.extract(f"test {first} {second}") 37 | 38 | assert extracts == ("test ", "foo", " ", {"bar": "baz"}) 39 | 40 | 41 | def test_format_context_in_elements() -> None: 42 | context = BaseElement.formatter 43 | assert context.get() == {} 44 | 45 | with context: 46 | f"test {b("foo")} {i("bar")}" 47 | assert list(context.get().values()) == [b("foo"), i("bar")] 48 | 49 | assert context.get() == {} 50 | 51 | with context: 52 | text = f"test {b("baz")} {i("foo")}" 53 | assert div(text) == div("test ", b("baz"), " ", i("foo")) 54 | assert div(f"test {div(f"foo {b("nested")}, {i("nested2")}")}") == div( 55 | "test ", 56 | div("foo ", b("nested"), ", ", i("nested2")), 57 | ) 58 | 59 | assert context.get() == {} 60 | 61 | 62 | def test_component_with_f_string() -> None: 63 | paragraph = Paragraph( 64 | f"Hello, how {strong("are you")}? Click {Link("here", to="https://example.com")}.", 65 | ) 66 | assert len(paragraph.children) == 5 67 | assert isinstance(paragraph.children[3], Link) 68 | assert paragraph.children[3].attrs["to"] == "https://example.com" 69 | assert paragraph.to_string(pretty=False) == ( 70 | "" 71 | 'Hello, how are you? ' 72 | 'Click here.' 73 | "" 74 | ) # fmt: skip 75 | assert paragraph.to_html() == ( 76 | "

" 77 | "Hello, how are you? Click " 78 | 'here.' 79 | "

" 80 | ) 81 | 82 | 83 | def test_escaping_works() -> None: 84 | link = 'test' 85 | dom = p(f"Hello, how are you? Click {link}.") 86 | assert dom.to_html() == ( 87 | "

Hello, how <b>are you</b>? " 88 | 'Click <a href="https://example.com">test</a>.

' 89 | ) 90 | 91 | 92 | def test_quotes_not_escaped() -> None: 93 | dom = p("It's alive <3.") 94 | assert dom.to_html() == "

It's alive <3.

" 95 | 96 | 97 | def test_attributes() -> None: 98 | assert format_attrs({"checked": False}, is_html=True) == {} 99 | assert format_attrs({"checked": True}, is_html=True) == {"checked": "checked"} 100 | 101 | assert format_attrs({"on_click": "test"}) == {"onclick": "test"} 102 | assert format_attrs({"hx_boost": True}) == {"hx-boost": "true"} 103 | assert format_attrs({"for_": "value"}) == {"for": "value"} 104 | assert format_attrs({"class_": "a b c"}) == {"class": "a b c"} 105 | assert format_attrs({"class_": "a b c", "classes": ["more", "classes"]}) == { 106 | "class": "a b c more classes" 107 | } 108 | 109 | assert format_attrs( 110 | { 111 | "hx_on_htmx_before_request": "alert('test')", 112 | "hx_on__after_request": "alert('test2')", 113 | } 114 | ) == { 115 | "hx-on-htmx-before-request": "alert('test')", 116 | "hx-on--after-request": "alert('test2')", 117 | } 118 | assert format_attrs( 119 | {"hx-on:htmx:before-request": "alert('Making a request!')"} 120 | ) == {"hx-on:htmx:before-request": "alert('Making a request!')"} 121 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | from ludic.html import div, script 2 | from ludic.types import JavaScript, Safe 3 | 4 | 5 | def test_safe() -> None: 6 | assert ( 7 | div("Hello World!").to_html() 8 | == "
Hello <b>World!</b>
" 9 | ) 10 | assert ( 11 | div(Safe("Hello World!")).to_html() == "
Hello World!
" 12 | ) 13 | 14 | 15 | def test_javascript() -> None: 16 | assert ( 17 | div("document.write('

HTML

');").to_html() 18 | == "
document.write('<h2>HTML</h2>');
" 19 | ) 20 | assert ( 21 | script(JavaScript("document.write('

HTML

');")).to_html() 22 | == "" 23 | ) 24 | -------------------------------------------------------------------------------- /tests/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getludic/ludic/6f972ea35cd55fbce5a03a88077410114bf77e92/tests/web/__init__.py -------------------------------------------------------------------------------- /tests/web/test_datastructures.py: -------------------------------------------------------------------------------- 1 | from ludic.web.datastructures import Headers 2 | 3 | 4 | def test_headers() -> None: 5 | headers = Headers({"foo": "bar", "bar": {"foo": "baz"}}) 6 | 7 | assert headers["foo"] == "bar" 8 | assert headers["bar"] == '{"foo": "baz"}' 9 | -------------------------------------------------------------------------------- /tests/web/test_parsers.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import sys 3 | from typing import Annotated, Any, Literal, TypedDict 4 | 5 | import pytest 6 | from starlette.datastructures import FormData 7 | 8 | from ludic.catalog.forms import FieldMeta 9 | from ludic.web.parsers import ListParser, Parser, ValidationError 10 | 11 | 12 | def parse_bool(value: Literal["on", "off"]) -> bool: 13 | return True if value == "on" else False 14 | 15 | 16 | class Example(TypedDict): 17 | sample_str: Annotated[str, FieldMeta()] 18 | sample_int: Annotated[int, FieldMeta(parser=int)] 19 | sample_bool: Annotated[bool, FieldMeta(parser=parse_bool)] 20 | 21 | 22 | class ExampleOptional(TypedDict, total=False): 23 | sample_optional: Annotated[str, FieldMeta()] 24 | 25 | 26 | class InvalidExample(TypedDict): 27 | sample_invalid: Annotated[int, FieldMeta()] # missing parser=int 28 | 29 | 30 | def test_parse_form_data() -> None: 31 | data = FormData({"sample_str": "test", "sample_int": "10", "sample_bool": "on"}) 32 | assert Parser[Example](data).validate() == Example( 33 | sample_str="test", 34 | sample_int=10, 35 | sample_bool=True, 36 | ) 37 | 38 | 39 | def test_parse_invalid_form_data() -> None: 40 | data = FormData({"sample_str": "test", "sample_int": "10a", "sample_bool": "on"}) 41 | with pytest.raises(ValidationError): 42 | _ = Parser[Example](data).validate() 43 | 44 | 45 | def test_parse_invalid_example() -> None: 46 | data = FormData({"sample_invalid": "10"}) 47 | with pytest.raises(ValidationError): 48 | _ = Parser[InvalidExample](data).validate() 49 | 50 | 51 | def test_parse_empty_valid() -> None: 52 | data = FormData({}) 53 | assert Parser[ExampleOptional](data).validate() == {} 54 | 55 | 56 | def test_parse_list_form_data() -> None: 57 | data = FormData( 58 | { 59 | "sample_str:_index:0": "test2", 60 | "sample_int:_index:0": "90", 61 | "sample_bool:_index:0": "off", 62 | "sample_str:_index:1": "test1", 63 | "sample_int:_index:1": "10", 64 | "sample_bool:_index:1": "on", 65 | } 66 | ) 67 | assert ListParser[Example](data).validate() == [ 68 | Example( 69 | sample_str="test2", 70 | sample_int=90, 71 | sample_bool=False, 72 | ), 73 | Example( 74 | sample_str="test1", 75 | sample_int=10, 76 | sample_bool=True, 77 | ), 78 | ] 79 | 80 | 81 | def test_parse_list_invalid_form_data() -> None: 82 | data = FormData( 83 | { 84 | "sample_str:_index:0": "test2", 85 | "sample_int:_index:0": "90abc", 86 | "sample_bool:_index:0": "off", 87 | } 88 | ) 89 | with pytest.raises(ValidationError): 90 | _ = ListParser[Example](data).validate() 91 | 92 | 93 | def test_parse_list_invalid_example() -> None: 94 | data = FormData({"sample_invalid:_intex:0": "10"}) 95 | with pytest.raises(ValidationError): 96 | _ = ListParser[InvalidExample](data).validate() 97 | 98 | 99 | def test_parse_list_empty_invalid() -> None: 100 | data = FormData({"sample_str": "10"}) 101 | with pytest.raises(ValidationError): 102 | _ = ListParser[Example](data).validate() 103 | 104 | 105 | def test_parse_list_empty_valid() -> None: 106 | data = FormData({}) 107 | assert ListParser[ExampleOptional](data).validate() == [] 108 | 109 | 110 | def test_module_can_be_imported_without_typeguard( 111 | monkeypatch: pytest.MonkeyPatch, 112 | ) -> None: 113 | def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: 114 | if name == "typeguard": 115 | raise ImportError("No module named 'some_module'") 116 | return real_import(name, *args, **kwargs) 117 | 118 | real_import = builtins.__import__ 119 | monkeypatch.setattr(builtins, "__import__", fake_import) 120 | sys.modules.pop("typeguard") 121 | sys.modules.pop("ludic.web.parsers") 122 | 123 | from ludic.web.parsers import check_type 124 | 125 | result = check_type(1, int) 126 | assert result == 1 127 | -------------------------------------------------------------------------------- /tests/web/test_requests.py: -------------------------------------------------------------------------------- 1 | from ludic.web.requests import join_mounts 2 | 3 | 4 | def test_join_mounts() -> None: 5 | assert join_mounts("", "") == "" 6 | assert join_mounts("a", "b") == "a:b" 7 | assert join_mounts("a:b", "c") == "a:b:c" 8 | assert join_mounts("foo:bar", "bar:Baz") == "foo:bar:Baz" 9 | assert join_mounts("foo:bar:Baz", "bar:Baz:foo") == "foo:bar:Baz:foo" 10 | -------------------------------------------------------------------------------- /tests/web/test_routing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.testclient import TestClient 3 | 4 | from ludic.html import div 5 | from ludic.web import LudicApp 6 | from ludic.web.datastructures import Headers 7 | 8 | app = LudicApp() 9 | 10 | 11 | @app.get("/mandatory-param/{test}") 12 | def mandatory_param(test: str) -> div: 13 | return div(test) 14 | 15 | 16 | @app.get("/extract-headers/") 17 | def extract_headers(headers: Headers) -> div: 18 | return div(headers["X-Foo"]) 19 | 20 | 21 | @app.get("/kw-only-params") 22 | def kw_only_params(*, bar: str | None) -> div: 23 | return div(bar or "nothing") 24 | 25 | 26 | @app.get("/invalid-signature") 27 | def invalid_signature(*, bar: str | int) -> div: 28 | return div(bar or "nothing") 29 | 30 | 31 | def test_mandatory_param() -> None: 32 | with TestClient(app) as client: 33 | response = client.get("/mandatory-param/value") 34 | assert response.content.decode("utf-8") == "
value
" 35 | 36 | 37 | def test_extract_headers() -> None: 38 | with TestClient(app) as client: 39 | response = client.get("/extract-headers", headers={"X-Foo": "x-foo-value"}) 40 | assert response.content.decode("utf-8") == "
x-foo-value
" 41 | 42 | 43 | def test_kw_only_params() -> None: 44 | with TestClient(app) as client: 45 | response = client.get("/kw-only-params?bar=something") 46 | assert response.content.decode("utf-8") == "
something
" 47 | response = client.get("/kw-only-params") 48 | assert response.content.decode("utf-8") == "
nothing
" 49 | 50 | 51 | def test_invalid_signature() -> None: 52 | with TestClient(app) as client, pytest.raises(TypeError): 53 | client.get("/invalid-signature?bar=something") 54 | --------------------------------------------------------------------------------