├── .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 |
3 |
4 |
5 | [](https://github.com/getludic/ludic/actions) [](https://codecov.io/gh/getludic/ludic) [](https://www.python.org/downloads/release/python-3130/) [](http://mypy-lang.org/) [](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 |
3 |
4 |
5 | [](https://github.com/getludic/ludic/actions) [](https://codecov.io/gh/getludic/ludic) [](https://www.python.org/downloads/release/python-312/) [](http://mypy-lang.org/) [](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}{name}>"
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}{self.html_name}>"
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() == "