├── tests ├── __init__.py ├── templatetags │ ├── __init__.py │ ├── test_prop.py │ ├── test_var.py │ └── test_slot.py ├── test_views.py ├── test_version.py ├── templates │ ├── without_bird.html │ └── with_bird.html ├── test_conf.py ├── test_fixtures.py ├── test_integration.py ├── settings.py ├── test_utils.py ├── test_templates.py ├── utils.py └── conftest.py ├── src └── django_bird │ ├── py.typed │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── generate_asset_manifest.py │ ├── templatetags │ ├── __init__.py │ ├── tags │ │ ├── __init__.py │ │ ├── prop.py │ │ ├── slot.py │ │ ├── var.py │ │ ├── bird.py │ │ └── asset.py │ └── django_bird.py │ ├── views.py │ ├── plugins │ ├── __init__.py │ ├── manager.py │ └── hookspecs.py │ ├── __init__.py │ ├── urls.py │ ├── _typing.py │ ├── utils.py │ ├── apps.py │ ├── conf.py │ ├── params.py │ ├── manifest.py │ ├── components.py │ ├── templates.py │ └── staticfiles.py ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── AUTHORS.md ├── docs ├── project │ ├── roadmap.md │ ├── changelog.md │ └── motivation.md ├── development │ ├── releasing.md │ ├── contributing.md │ └── just.md ├── _static │ └── css │ │ └── custom.css ├── index.md ├── plugins.md ├── naming.md ├── conf.py ├── vars.md ├── configuration.md ├── assets.md ├── slots.md └── organization.md ├── .vscode ├── settings.json.example └── extensions.json ├── .just ├── project.just ├── documentation.just └── copier.just ├── .editorconfig ├── .readthedocs.yaml ├── .copier └── package.yml ├── LICENSE ├── Justfile ├── .pre-commit-config.yaml ├── RELEASING.md ├── .gitignore ├── noxfile.py ├── CONTRIBUTING.md ├── pyproject.toml ├── .bin └── bump.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_bird/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_bird/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_bird/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @joshuadavidthomas 2 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /src/django_bird/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | - Josh Thomas 4 | -------------------------------------------------------------------------------- /docs/project/roadmap.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../ROADMAP.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/development/releasing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../RELEASING.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/project/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CHANGELOG.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /src/django_bird/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .manager import pm 4 | 5 | __all__ = [ 6 | "pm", 7 | ] 8 | -------------------------------------------------------------------------------- /docs/development/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CONTRIBUTING.md 2 | 3 | ``` 4 | 5 | See the [documentation](./just.md) for more information. 6 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_bird import __version__ 4 | 5 | 6 | def test_version(): 7 | assert __version__ == "0.17.3" 8 | -------------------------------------------------------------------------------- /src/django_bird/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pluggy import HookimplMarker 4 | 5 | __version__ = "0.17.3" 6 | 7 | hookimpl = HookimplMarker("django_bird") 8 | -------------------------------------------------------------------------------- /src/django_bird/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .apps import DjangoBirdAppConfig 4 | 5 | app_name = DjangoBirdAppConfig.label 6 | 7 | urlpatterns = [] # type: ignore[var-annotated] 8 | -------------------------------------------------------------------------------- /tests/templates/without_bird.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 |
3 |

{{ title }}

4 |

{{ content }}

5 | 6 |
7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/templates/with_bird.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 |
3 |

{{ title }}

4 |

{{ content }}

5 | Click me 6 |
7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /docs/project/motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | ```{include} ../../README.md 4 | :start-after: '' 5 | :end-before: '' 6 | ``` 7 | 8 | See the [ROADMAP](./roadmap.md) for planned features and future direction of django-bird. 9 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from django_bird.conf import app_settings 6 | 7 | 8 | @pytest.mark.default_app_settings 9 | def test_app_settings(): 10 | assert app_settings.COMPONENT_DIRS == [] 11 | assert app_settings.ENABLE_BIRD_ATTRS is True 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | timezone: America/Chicago 8 | labels: 9 | - 🤖 dependabot 10 | groups: 11 | gha: 12 | patterns: 13 | - "*" 14 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* 2 | no idea if this will screw a lot up with the furo theme 3 | but this does fix the line of badges in the README. only 4 | one of them has a link which furo makes the vertical-align 5 | different than just a standard img 6 | */ 7 | p a.reference img { 8 | vertical-align: inherit; 9 | } 10 | -------------------------------------------------------------------------------- /src/django_bird/_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info >= (3, 12): 6 | from typing import override as typing_override 7 | else: 8 | from typing_extensions import override as typing_override 9 | 10 | override = typing_override 11 | 12 | TagBits = list[str] 13 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.associations": { 4 | "Justfile": "just" 5 | }, 6 | "ruff.organizeImports": true, 7 | "[django-html][handlebars][hbs][mustache][jinja][jinja-html][nj][njk][nunjucks][twig]": { 8 | "editor.defaultFormatter": "monosans.djlint" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "monosans.djlint", 7 | "ms-python.black-formatter", 8 | "ms-python.pylint", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance", 11 | "skellock.just", 12 | "tamasfe.even-better-toml" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.just/project.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/project.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | [no-cd] 14 | @bump *ARGS: 15 | {{ justfile_directory() }}/.bin/bump.py version {{ ARGS }} 16 | 17 | [no-cd] 18 | @release *ARGS: 19 | {{ justfile_directory() }}/.bin/bump.py release {{ ARGS }} 20 | -------------------------------------------------------------------------------- /tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.template.loader import get_template 4 | 5 | from .utils import TestComponent 6 | 7 | 8 | def test_test_component(templates_dir): 9 | name = "foo" 10 | content = "
bar
" 11 | 12 | TestComponent(name=name, content=content).create(templates_dir) 13 | 14 | template = get_template(f"bird/{name}.html") 15 | 16 | assert template 17 | assert template.render({}) == content 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{,.}{j,J}ustfile] 12 | indent_size = 4 13 | 14 | [*.{py,rst,ini,md}] 15 | indent_size = 4 16 | 17 | [*.py] 18 | line_length = 120 19 | multi_line_output = 3 20 | 21 | [*.{css,html,js,json,jsx,sass,scss,svelte,ts,tsx,yml,yaml}] 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [{Makefile,*.bat}] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /src/django_bird/plugins/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | 5 | import pluggy 6 | 7 | from django_bird.plugins import hookspecs 8 | 9 | pm = pluggy.PluginManager("django_bird") 10 | pm.add_hookspecs(hookspecs) 11 | 12 | pm.load_setuptools_entrypoints("django-bird") 13 | 14 | DEFAULT_PLUGINS: list[str] = [ 15 | "django_bird.conf", 16 | "django_bird.staticfiles", 17 | "django_bird.templates", 18 | ] 19 | 20 | for plugin in DEFAULT_PLUGINS: 21 | mod = importlib.import_module(plugin) 22 | pm.register(mod, plugin) 23 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | commands: 11 | - asdf plugin add just && asdf install just latest && asdf global just latest 12 | - asdf plugin add uv && asdf install uv latest && asdf global uv latest 13 | - just docs cog 14 | - uv run --group docs sphinx-build docs $READTHEDOCS_OUTPUT/html 15 | 16 | sphinx: 17 | configuration: docs/conf.py 18 | 19 | formats: 20 | - pdf 21 | - epub 22 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.template.loader import get_template 4 | 5 | 6 | def test_template_inheritance_assets(example_template): 7 | rendered = get_template(example_template.template.name).render({}) 8 | 9 | assert all( 10 | asset.url in rendered 11 | for component in example_template.used_components 12 | for asset in component.assets 13 | ) 14 | assert not any( 15 | asset.url in rendered 16 | for component in example_template.unused_components 17 | for asset in component.assets 18 | ) 19 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/django_bird.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django import template 4 | 5 | from .tags import asset 6 | from .tags import bird 7 | from .tags import prop 8 | from .tags import slot 9 | from .tags import var 10 | 11 | register = template.Library() 12 | 13 | 14 | register.tag(asset.AssetTag.CSS.value, asset.do_asset) 15 | register.tag(asset.AssetTag.JS.value, asset.do_asset) 16 | register.tag(bird.TAG, bird.do_bird) 17 | register.tag(prop.TAG, prop.do_prop) 18 | register.tag(slot.TAG, slot.do_slot) 19 | register.tag(var.TAG, var.do_var) 20 | register.tag(var.END_TAG, var.do_end_var) 21 | -------------------------------------------------------------------------------- /.copier/package.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v2024.27-4-g3fe659b 3 | _src_path: /home/josh/projects/work/django-twc-package 4 | author_email: josh@joshthomas.dev 5 | author_name: Josh Thomas 6 | current_version: 0.17.3 7 | django_versions: 8 | - "4.2" 9 | - "5.0" 10 | - "5.1" 11 | docs_domain: readthedocs.io 12 | github_owner: joshuadavidthomas 13 | github_repo: bird 14 | module_name: bird 15 | package_description: A UI Kit for your Django projects 16 | package_name: bird 17 | python_versions: 18 | - "3.10" 19 | - "3.11" 20 | - "3.12" 21 | - "3.13" 22 | test_django_main: true 23 | versioning_scheme: SemVer 24 | -------------------------------------------------------------------------------- /src/django_bird/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from collections.abc import Iterable 5 | from pathlib import Path 6 | from typing import Any 7 | from typing import TypeVar 8 | 9 | 10 | def get_files_from_dirs( 11 | dirs: Iterable[Path], 12 | pattern: str = "*", 13 | ) -> Generator[tuple[Path, Path], Any, None]: 14 | for dir in dirs: 15 | for path in dir.rglob(pattern): 16 | if path.is_file(): 17 | yield path, dir 18 | 19 | 20 | Item = TypeVar("Item") 21 | 22 | 23 | def unique_ordered(items: Iterable[Item]) -> list[Item]: 24 | return list(dict.fromkeys(items)) 25 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEFAULT_SETTINGS = { 4 | "ALLOWED_HOSTS": ["*"], 5 | "DEBUG": False, 6 | "CACHES": { 7 | "default": { 8 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 9 | } 10 | }, 11 | "DATABASES": { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | } 16 | }, 17 | "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", 18 | "LOGGING_CONFIG": None, 19 | "PASSWORD_HASHERS": [ 20 | "django.contrib.auth.hashers.MD5PasswordHasher", 21 | ], 22 | "SECRET_KEY": "not-a-secret", 23 | } 24 | -------------------------------------------------------------------------------- /src/django_bird/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import final 4 | 5 | from django.apps import AppConfig 6 | 7 | from ._typing import override 8 | 9 | 10 | @final 11 | class DjangoBirdAppConfig(AppConfig): 12 | label = "django_bird" 13 | name = "django_bird" 14 | verbose_name = "Bird" 15 | 16 | @override 17 | def ready(self): 18 | from django_bird.plugins import pm 19 | from django_bird.staticfiles import asset_types 20 | 21 | for pre_ready in pm.hook.pre_ready(): 22 | pre_ready() 23 | 24 | pm.hook.register_asset_types(register_type=asset_types.register_type) 25 | 26 | for ready in pm.hook.ready(): 27 | ready() 28 | -------------------------------------------------------------------------------- /.just/documentation.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/documentation.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Build documentation using Sphinx 14 | [no-cd] 15 | build LOCATION="docs/_build/html": cog 16 | uv run --group docs sphinx-build docs {{ LOCATION }} 17 | 18 | # Serve documentation locally 19 | [no-cd] 20 | serve PORT="8000": cog 21 | #!/usr/bin/env sh 22 | HOST="localhost" 23 | if [ -f "/.dockerenv" ]; then 24 | HOST="0.0.0.0" 25 | fi 26 | uv run --group docs sphinx-autobuild docs docs/_build/html --host "$HOST" --port {{ PORT }} 27 | 28 | [no-cd] 29 | [private] 30 | cog: 31 | uv run --with cogapp cog -r CONTRIBUTING.md docs/development/just.md docs/plugins.md 32 | -------------------------------------------------------------------------------- /src/django_bird/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from dataclasses import field 5 | from pathlib import Path 6 | 7 | from django.conf import settings 8 | 9 | from ._typing import override 10 | 11 | DJANGO_BIRD_SETTINGS_NAME = "DJANGO_BIRD" 12 | 13 | DJANGO_BIRD_BUILTINS = "django_bird.templatetags.django_bird" 14 | DJANGO_BIRD_FINDER = "django_bird.staticfiles.BirdAssetFinder" 15 | 16 | 17 | @dataclass 18 | class AppSettings: 19 | ADD_ASSET_PREFIX: bool | None = None 20 | COMPONENT_DIRS: list[Path | str] = field(default_factory=list) 21 | ENABLE_BIRD_ATTRS: bool = True 22 | 23 | @override 24 | def __getattribute__(self, __name: str) -> object: 25 | user_settings = getattr(settings, DJANGO_BIRD_SETTINGS_NAME, {}) 26 | return user_settings.get(__name, super().__getattribute__(__name)) 27 | 28 | 29 | app_settings = AppSettings() 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | test: 9 | uses: ./.github/workflows/test.yml 10 | secrets: inherit 11 | 12 | pypi: 13 | runs-on: ubuntu-latest 14 | needs: test 15 | environment: release 16 | permissions: 17 | contents: write 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | enable-cache: true 26 | pyproject-file: pyproject.toml 27 | 28 | - name: Build package 29 | run: | 30 | uv build 31 | 32 | - name: Upload release assets to GitHub 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: | 36 | gh release upload ${{ github.event.release.tag_name }} ./dist/* 37 | 38 | - name: Publish to PyPI 39 | run: | 40 | uv publish 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | :start-after: '' 3 | :end-before: '' 4 | ``` 5 | 6 | ```{include} ../README.md 7 | :start-after: '' 8 | :end-before: '' 9 | ``` 10 | 11 | ```{toctree} 12 | :hidden: 13 | :maxdepth: 3 14 | Naming 15 | Attributes/Properties 16 | Variables 17 | slots 18 | Assets 19 | Organization 20 | Plugins 21 | configuration 22 | ``` 23 | 24 | ```{toctree} 25 | :hidden: 26 | :maxdepth: 2 27 | :caption: Project 28 | 29 | project/changelog.md 30 | project/motivation.md 31 | project/roadmap.md 32 | ``` 33 | 34 | ```{toctree} 35 | :hidden: 36 | :maxdepth: 3 37 | :caption: API Reference 38 | 39 | apidocs/django_bird/django_bird.rst 40 | ``` 41 | 42 | ```{toctree} 43 | :hidden: 44 | :maxdepth: 2 45 | :caption: Development 46 | 47 | development/contributing.md 48 | development/just.md 49 | Releasing 50 | ``` 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Josh Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /tests/templatetags/test_prop.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django import template 5 | from django.template.base import Parser 6 | from django.template.base import Token 7 | from django.template.base import TokenType 8 | 9 | from django_bird.templatetags.tags.prop import TAG 10 | from django_bird.templatetags.tags.prop import PropNode 11 | from django_bird.templatetags.tags.prop import do_prop 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "contents,expected", 16 | [ 17 | ("id", PropNode(name="id", default=None, attrs=[])), 18 | ("class='btn'", PropNode(name="class", default="'btn'", attrs=[])), 19 | ], 20 | ) 21 | def test_do_prop(contents, expected): 22 | start_token = Token(TokenType.BLOCK, f"{TAG} {contents}") 23 | 24 | node = do_prop(Parser([]), start_token) 25 | 26 | assert node.name == expected.name 27 | assert node.default == expected.default 28 | assert node.attrs == expected.attrs 29 | 30 | 31 | def test_do_prop_no_args(): 32 | start_token = Token(TokenType.BLOCK, TAG) 33 | 34 | with pytest.raises(template.TemplateSyntaxError): 35 | do_prop(Parser([]), start_token) 36 | -------------------------------------------------------------------------------- /.just/copier.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/copier.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Create a copier answers file 14 | [no-cd] 15 | copy TEMPLATE_PATH DESTINATION_PATH=".": 16 | uv run copier copy --trust {{ TEMPLATE_PATH }} {{ DESTINATION_PATH }} 17 | 18 | # Recopy the project from the original template 19 | [no-cd] 20 | recopy ANSWERS_FILE *ARGS: 21 | uv run copier recopy --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 22 | 23 | # Loop through all answers files and recopy the project using copier 24 | [no-cd] 25 | @recopy-all *ARGS: 26 | for file in `ls .copier/`; do just copier recopy .copier/$file "{{ ARGS }}"; done 27 | 28 | # Update the project using a copier answers file 29 | [no-cd] 30 | update ANSWERS_FILE *ARGS: 31 | uv run copier update --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 32 | 33 | # Loop through all answers files and update the project using copier 34 | [no-cd] 35 | @update-all *ARGS: 36 | for file in `ls .copier/`; do just copier update .copier/$file "{{ ARGS }}"; done 37 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/prop.py: -------------------------------------------------------------------------------- 1 | # pyright: reportAny=false 2 | from __future__ import annotations 3 | 4 | from typing import final 5 | 6 | from django import template 7 | from django.template.base import Parser 8 | from django.template.base import Token 9 | from django.template.context import Context 10 | 11 | from django_bird._typing import TagBits 12 | from django_bird._typing import override 13 | 14 | TAG = "bird:prop" 15 | 16 | 17 | def do_prop(_parser: Parser, token: Token) -> PropNode: 18 | _tag, *bits = token.split_contents() 19 | if not bits: 20 | msg = f"{TAG} tag requires at least one argument" 21 | raise template.TemplateSyntaxError(msg) 22 | 23 | prop = bits.pop(0) 24 | 25 | try: 26 | name, default = prop.split("=") 27 | except ValueError: 28 | name = prop 29 | default = None 30 | 31 | return PropNode(name, default, bits) 32 | 33 | 34 | @final 35 | class PropNode(template.Node): 36 | def __init__(self, name: str, default: str | None, attrs: TagBits): 37 | self.name = name 38 | self.default = default 39 | self.attrs = attrs 40 | 41 | @override 42 | def render(self, context: Context) -> str: 43 | return "" 44 | -------------------------------------------------------------------------------- /src/django_bird/management/commands/generate_asset_manifest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import ArgumentParser 4 | from typing import Any 5 | from typing import final 6 | 7 | from django.core.management.base import BaseCommand 8 | 9 | from django_bird._typing import override 10 | from django_bird.manifest import default_manifest_path 11 | from django_bird.manifest import generate_asset_manifest 12 | from django_bird.manifest import save_asset_manifest 13 | 14 | 15 | @final 16 | class Command(BaseCommand): 17 | help: str = ( 18 | "Generates a manifest of component usage in templates for loading assets" 19 | ) 20 | 21 | @override 22 | def add_arguments(self, parser: ArgumentParser) -> None: 23 | parser.add_argument( 24 | "--output", 25 | type=str, 26 | default=None, 27 | help="Path where the manifest file should be saved. Defaults to STATIC_ROOT/django_bird/manifest.json", 28 | ) 29 | 30 | @override 31 | def handle(self, *args: Any, **options: Any) -> None: 32 | manifest_data = generate_asset_manifest() 33 | output_path = options["output"] or default_manifest_path() 34 | save_asset_manifest(manifest_data, output_path) 35 | self.stdout.write( 36 | self.style.SUCCESS( 37 | f"Asset manifest generated successfully at {output_path}" 38 | ) 39 | ) 40 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | set unstable := true 3 | 4 | mod copier ".just/copier.just" 5 | mod docs ".just/documentation.just" 6 | mod project ".just/project.just" 7 | 8 | [private] 9 | default: 10 | @just --list --list-submodules 11 | 12 | [private] 13 | diff SHA="HEAD": 14 | #!/usr/bin/env bash 15 | LATEST_TAG=$(git describe --tags --abbrev=0) 16 | GIT_PAGER=cat git diff "$LATEST_TAG"..{{ SHA }} src/ 17 | 18 | [private] 19 | fmt: 20 | @just --fmt 21 | @just copier fmt 22 | @just docs fmt 23 | @just project fmt 24 | 25 | [private] 26 | nox SESSION *ARGS: 27 | uv run nox --session "{{ SESSION }}" -- "{{ ARGS }}" 28 | 29 | bootstrap: 30 | uv python install 31 | uv sync --locked 32 | 33 | coverage *ARGS: 34 | @just nox coverage {{ ARGS }} 35 | 36 | lint: 37 | @just nox lint 38 | 39 | lock *ARGS: 40 | uv lock {{ ARGS }} 41 | 42 | manage *COMMAND: 43 | #!/usr/bin/env python 44 | import sys 45 | 46 | try: 47 | from django.conf import settings 48 | from django.core.management import execute_from_command_line 49 | except ImportError as exc: 50 | raise ImportError( 51 | "Couldn't import Django. Are you sure it's installed and " 52 | "available on your PYTHONPATH environment variable? Did you " 53 | "forget to activate a virtual environment?" 54 | ) from exc 55 | 56 | settings.configure(INSTALLED_APPS=["django_bird"]) 57 | execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 58 | 59 | mypy *ARGS: 60 | @just nox mypy {{ ARGS }} 61 | 62 | pyright *ARGS: 63 | @just nox pyright {{ ARGS }} 64 | 65 | test *ARGS: 66 | @just nox test {{ ARGS }} 67 | 68 | testall *ARGS: 69 | @just nox tests {{ ARGS }} 70 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/slot.py: -------------------------------------------------------------------------------- 1 | # pyright: reportAny=false 2 | from __future__ import annotations 3 | 4 | from typing import cast 5 | from typing import final 6 | 7 | from django import template 8 | from django.template.base import NodeList 9 | from django.template.base import Parser 10 | from django.template.base import Token 11 | from django.template.context import Context 12 | from django.utils.safestring import SafeString 13 | 14 | from django_bird._typing import override 15 | 16 | TAG = "bird:slot" 17 | END_TAG = "endbird:slot" 18 | 19 | DEFAULT_SLOT = "default" 20 | 21 | 22 | def do_slot(parser: Parser, token: Token) -> SlotNode: 23 | _tag, *bits = token.split_contents() 24 | if len(bits) > 1: 25 | msg = f"{TAG} tag requires either one or no arguments" 26 | raise template.TemplateSyntaxError(msg) 27 | 28 | if len(bits) == 0: 29 | name = DEFAULT_SLOT 30 | else: 31 | name = bits[0] 32 | if name.startswith("name="): 33 | _, name = name.split("=") 34 | name = name.strip("'\"") 35 | 36 | nodelist = parser.parse((END_TAG,)) 37 | parser.delete_first_token() 38 | 39 | return SlotNode(name, nodelist) 40 | 41 | 42 | @final 43 | class SlotNode(template.Node): 44 | def __init__(self, name: str, nodelist: NodeList): 45 | self.name = name 46 | self.nodelist = nodelist 47 | 48 | @override 49 | def render(self, context: Context) -> SafeString: 50 | slots = context.get("slots") 51 | 52 | if not slots or not isinstance(slots, dict): 53 | return self.nodelist.render(context) 54 | 55 | slots_dict = cast(dict[str, str], slots) 56 | slot_content = slots_dict.get(self.name) 57 | 58 | if slot_content is None or slot_content == "": 59 | return self.nodelist.render(context) 60 | 61 | return template.Template(slot_content).render(context) 62 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-toml 8 | - id: check-yaml 9 | 10 | - repo: https://github.com/adamchainz/django-upgrade 11 | rev: 1.25.0 12 | hooks: 13 | - id: django-upgrade 14 | args: [--target-version, "4.2"] 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.11.13 18 | hooks: 19 | - id: ruff 20 | args: [--fix] 21 | - id: ruff-format 22 | 23 | - repo: https://github.com/adamchainz/blacken-docs 24 | rev: 1.19.1 25 | hooks: 26 | - id: blacken-docs 27 | alias: autoformat 28 | additional_dependencies: 29 | - black==22.12.0 30 | 31 | - repo: https://github.com/pre-commit/mirrors-prettier 32 | rev: v4.0.0-alpha.8 33 | hooks: 34 | - id: prettier 35 | # lint the following with prettier: 36 | # - javascript 37 | # - typescript 38 | # - JSX/TSX 39 | # - CSS 40 | # - yaml 41 | # ignore any minified code 42 | files: '^(?!.*\.min\..*)(?P[\w-]+(\.[\w-]+)*\.(js|jsx|ts|tsx|yml|yaml|css))$' 43 | 44 | - repo: https://github.com/djlint/djLint 45 | rev: v1.36.4 46 | hooks: 47 | - id: djlint-reformat-django 48 | - id: djlint-django 49 | 50 | - repo: local 51 | hooks: 52 | - id: rustywind 53 | name: rustywind Tailwind CSS class linter 54 | language: node 55 | additional_dependencies: 56 | - rustywind@0.21.0 57 | entry: rustywind 58 | args: [--write] 59 | types_or: [html, jsx, tsx] 60 | 61 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 62 | rev: v2.14.0 63 | hooks: 64 | - id: pretty-format-toml 65 | args: [--autofix] 66 | 67 | - repo: https://github.com/abravalheri/validate-pyproject 68 | rev: v0.24.1 69 | hooks: 70 | - id: validate-pyproject 71 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | django-bird uses a plugin system based on [pluggy](https://pluggy.readthedocs.io/) to allow extending its functionality. 4 | 5 | ## Available Hooks 6 | 7 | ````{py:function} collect_component_assets(template_path: pathlib.Path) -> collections.abc.Iterable[django_bird.staticfiles.Asset] 8 | :canonical: django_bird.plugins.hookspecs.collect_component_assets 9 | 10 | ```{autodoc2-docstring} django_bird.plugins.hookspecs.collect_component_assets 11 | :parser: myst 12 | ``` 13 | ```` 14 | 15 | ````{py:function} get_template_directories() -> list[pathlib.Path] 16 | :canonical: django_bird.plugins.hookspecs.get_template_directories 17 | 18 | ```{autodoc2-docstring} django_bird.plugins.hookspecs.get_template_directories 19 | :parser: myst 20 | ``` 21 | ```` 22 | 23 | ````{py:function} pre_ready() -> None 24 | :canonical: django_bird.plugins.hookspecs.pre_ready 25 | 26 | ```{autodoc2-docstring} django_bird.plugins.hookspecs.pre_ready 27 | :parser: myst 28 | ``` 29 | ```` 30 | 31 | ````{py:function} ready() -> None 32 | :canonical: django_bird.plugins.hookspecs.ready 33 | 34 | ```{autodoc2-docstring} django_bird.plugins.hookspecs.ready 35 | :parser: myst 36 | ``` 37 | ```` 38 | 39 | ````{py:function} register_asset_types(register_type: collections.abc.Callable[[django_bird.staticfiles.AssetType], None]) -> None 40 | :canonical: django_bird.plugins.hookspecs.register_asset_types 41 | 42 | ```{autodoc2-docstring} django_bird.plugins.hookspecs.register_asset_types 43 | :parser: myst 44 | ``` 45 | ```` 46 | 47 | ## Creating a Plugin 48 | 49 | To create a plugin: 50 | 51 | 1. Create a Python package for your plugin 52 | 2. Import the `django_bird.hookimpl` marker: 53 | 54 | ```python 55 | from django_bird import hookimpl 56 | ``` 57 | 58 | 3. Implement one or more hooks using the `@hookimpl` decorator. 59 | 4. Register your plugin in your package's entry points: 60 | 61 | ```toml 62 | [project.entry-points."django_bird"] 63 | my_plugin = "my_plugin.module" 64 | ``` 65 | 66 | See the [pluggy documentation](https://pluggy.readthedocs.io/) for more details. 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_call: 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | 17 | jobs: 18 | generate-matrix: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | matrix: ${{ steps.set-matrix.outputs.matrix }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Install uv 26 | uses: astral-sh/setup-uv@v6 27 | with: 28 | enable-cache: true 29 | pyproject-file: pyproject.toml 30 | 31 | - id: set-matrix 32 | run: | 33 | uv run nox --session "gha_matrix" 34 | 35 | test: 36 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 37 | runs-on: ubuntu-latest 38 | needs: generate-matrix 39 | strategy: 40 | fail-fast: false 41 | matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Install uv 46 | uses: astral-sh/setup-uv@v6 47 | with: 48 | enable-cache: true 49 | pyproject-file: pyproject.toml 50 | 51 | - name: Run tests 52 | run: | 53 | uv run nox --session "tests(python='${{ matrix.python-version }}', django='${{ matrix.django-version }}')" -- --slow 54 | 55 | tests: 56 | runs-on: ubuntu-latest 57 | needs: test 58 | if: always() 59 | steps: 60 | - name: OK 61 | if: ${{ !(contains(needs.*.result, 'failure')) }} 62 | run: exit 0 63 | - name: Fail 64 | if: ${{ contains(needs.*.result, 'failure') }} 65 | run: exit 1 66 | 67 | types: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - name: Install uv 73 | uses: astral-sh/setup-uv@v6 74 | with: 75 | enable-cache: true 76 | pyproject-file: pyproject.toml 77 | 78 | - name: Run type checks 79 | run: | 80 | uv run nox --session "mypy" 81 | 82 | coverage: 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - name: Install uv 88 | uses: astral-sh/setup-uv@v6 89 | with: 90 | enable-cache: true 91 | pyproject-file: pyproject.toml 92 | 93 | - name: Generate code coverage 94 | run: | 95 | uv run nox --session "coverage" 96 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from django_bird.utils import get_files_from_dirs 6 | from django_bird.utils import unique_ordered 7 | 8 | from .utils import normalize_whitespace 9 | 10 | 11 | class TestBirdUtils: 12 | def test_get_files_from_dirs(self, tmp_path): 13 | first = tmp_path / "first" 14 | first.mkdir() 15 | 16 | for i in range(10): 17 | file = first / f"to_find{i}.txt" 18 | file.write_text("file should be found") 19 | 20 | for i in range(10): 21 | file = first / f"do_not_find{i}.txt" 22 | file.write_text("file should not be found") 23 | 24 | second = tmp_path / "second" 25 | second.mkdir() 26 | 27 | for i in range(10): 28 | file = second / f"to_find{i}.txt" 29 | file.write_text("file should be found") 30 | 31 | for i in range(10): 32 | file = second / f"do_not_find{i}.txt" 33 | file.write_text("file should not be found") 34 | 35 | dirs = [first, second] 36 | 37 | found_paths = list(get_files_from_dirs(dirs, "to_find*.txt")) 38 | 39 | assert len(found_paths) == 20 40 | assert all("to_find" in path.name for path, _ in found_paths) 41 | assert not any("do_not_find" in path.name for path, _ in found_paths) 42 | 43 | @pytest.mark.parametrize( 44 | "items,expected", 45 | [ 46 | (["a", "b", "c", "a"], ["a", "b", "c"]), 47 | (["first", "second", "first", "third"], ["first", "second", "third"]), 48 | ([1, 2, 1, 3], [1, 2, 3]), 49 | ([1, "b", 1, "a"], [1, "b", "a"]), 50 | ], 51 | ) 52 | def test_unique_ordered(self, items, expected): 53 | assert unique_ordered(items) == expected 54 | 55 | 56 | class TestTestsUtils: 57 | @pytest.mark.parametrize( 58 | "contents,expected", 59 | [ 60 | ("", ""), 61 | ("", ""), 62 | ("", ""), 63 | ("", ""), 64 | ( 65 | "", 66 | "", 67 | ), 68 | ("\n\n", ""), 69 | ], 70 | ) 71 | def test_normalize_whitespace(self, contents, expected): 72 | assert normalize_whitespace(contents) == expected 73 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/var.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import Any 5 | from typing import final 6 | 7 | from django import template 8 | from django.template.base import Parser 9 | from django.template.base import Token 10 | from django.template.context import Context 11 | 12 | from django_bird._typing import override 13 | 14 | register = template.Library() 15 | 16 | TAG = "bird:var" 17 | END_TAG = "endbird:var" 18 | 19 | OPERATOR_PATTERN = re.compile(r"(\w+)\s*(\+=|=)\s*(.+)") 20 | 21 | 22 | def do_var(parser: Parser, token: Token): 23 | _tag, *bits = token.split_contents() 24 | if not bits: 25 | msg = f"'{TAG}' tag requires an assignment" 26 | raise template.TemplateSyntaxError(msg) 27 | 28 | var_assignment = bits.pop(0) 29 | match = re.match(OPERATOR_PATTERN, var_assignment) 30 | if not match: 31 | msg = ( 32 | f"Invalid assignment in '{TAG}' tag: {var_assignment}. " 33 | f"Expected format: {TAG} variable='value' or {TAG} variable+='value'." 34 | ) 35 | raise template.TemplateSyntaxError(msg) 36 | 37 | var_name, operator, var_value = match.groups() 38 | var_value = var_value.strip() 39 | value = parser.compile_filter(var_value) 40 | 41 | return VarNode(var_name, operator, value) 42 | 43 | 44 | def do_end_var(_parser: Parser, token: Token): 45 | _tag, *bits = token.split_contents() 46 | if not bits: 47 | msg = f"{token.contents.split()[0]} tag requires a variable name" 48 | raise template.TemplateSyntaxError(msg) 49 | 50 | var_name = bits.pop(0) 51 | 52 | return EndVarNode(var_name) 53 | 54 | 55 | @final 56 | class VarNode(template.Node): 57 | def __init__(self, name: str, operator: str, value: Any): 58 | self.name = name 59 | self.operator = operator 60 | self.value = value 61 | 62 | @override 63 | def render(self, context: Context) -> str: 64 | if "vars" not in context: 65 | context["vars"] = {} 66 | 67 | value = self.value.resolve(context) 68 | 69 | if self.operator == "+=": 70 | previous = context["vars"].get(self.name, "") 71 | value = f"{previous}{value}" 72 | 73 | context["vars"][self.name] = value 74 | return "" 75 | 76 | 77 | @final 78 | class EndVarNode(template.Node): 79 | def __init__(self, name: str): 80 | self.name = name 81 | 82 | @override 83 | def render(self, context: Context) -> str: 84 | if "vars" in context and self.name in context["vars"]: 85 | del context["vars"][self.name] 86 | return "" 87 | -------------------------------------------------------------------------------- /docs/naming.md: -------------------------------------------------------------------------------- 1 | # Naming Components 2 | 3 | Component names in django-bird are derived from their file locations and names within your templates directory. The naming system is flexible and supports both simple and complex component structures. 4 | 5 | For detailed organization patterns and real-world examples, see the [Organizing Components](organization.md) documentation. 6 | 7 | ## Basic Naming 8 | 9 | The simplest way to name a component is through its filename in the `bird` directory: 10 | 11 | ```bash 12 | templates/ 13 | └── bird/ 14 | └── button.html 15 | ``` 16 | 17 | This creates a component that can be used as: 18 | 19 | ```htmldjango 20 | {% bird button %} 21 | Click me! 22 | {% endbird %} 23 | ``` 24 | 25 | ## Nested Names 26 | 27 | Component names can include dots to represent directory structure: 28 | 29 | ```htmldjango 30 | {% bird icon.arrow-down / %} 31 | ``` 32 | 33 | This maps to either a file path with dots (`icon.arrow-down.html`): 34 | 35 | ```bash 36 | templates/ 37 | └── bird/ 38 | └── icon.arrow-down.html 39 | ``` 40 | 41 | Or a nested directory structure (`icon/arrow-down.html`): 42 | 43 | ```bash 44 | templates/ 45 | └── bird/ 46 | └── icon/ 47 | └── arrow-down.html 48 | ``` 49 | 50 | See [Organizing Components](organization.md) for detailed directory structure examples. 51 | 52 | ## Dynamic vs Literal Names 53 | 54 | Component names can be either dynamic or literal: 55 | 56 | ```htmldjango 57 | {# Dynamic name - resolves from context #} 58 | {% with component_name="icon.arrow-down" %} 59 | {% bird component_name / %} 60 | {% endwith %} 61 | 62 | {# Literal name - always uses "button" #} 63 | {% bird "button" / %} 64 | {% bird 'button' / %} 65 | ``` 66 | 67 | When using an unquoted name, django-bird will attempt to resolve it from the template context. This is useful when the component choice needs to be determined at runtime in your Django view. 68 | 69 | Using quoted names (single or double quotes) ensures the literal string is used as the component name, bypassing context resolution. This is useful when you want to ensure a specific component is always used, even if a variable with the same name exists in the context. 70 | 71 | ## Template Resolution 72 | 73 | django-bird follows these rules when looking for component templates: 74 | 75 | 1. Searches custom directories specified in `COMPONENT_DIRS` setting, then the default `bird` directory 76 | 2. For a component in a directory (e.g., `accordion`), looks for: 77 | - `accordion/accordion.html` 78 | - `accordion/index.html` 79 | - `accordion.html` 80 | 81 | This flexibility allows you to organize components according to your project's needs while maintaining consistent usage patterns.s 82 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/bird.py: -------------------------------------------------------------------------------- 1 | # pyright: reportAny=false 2 | from __future__ import annotations 3 | 4 | from typing import final 5 | 6 | from django import template 7 | from django.template.base import NodeList 8 | from django.template.base import Parser 9 | from django.template.base import Token 10 | from django.template.context import Context 11 | 12 | from django_bird._typing import TagBits 13 | from django_bird._typing import override 14 | 15 | TAG = "bird" 16 | END_TAG = "endbird" 17 | 18 | 19 | def do_bird(parser: Parser, token: Token) -> BirdNode: 20 | _tag, *bits = token.split_contents() 21 | if not bits: 22 | msg = f"{TAG} tag requires at least one argument" 23 | raise template.TemplateSyntaxError(msg) 24 | 25 | name = bits.pop(0) 26 | attrs: TagBits = [] 27 | isolated_context = False 28 | 29 | for bit in bits: 30 | match bit: 31 | case "only": 32 | isolated_context = True 33 | case "/": 34 | continue 35 | case _: 36 | attrs.append(bit) 37 | 38 | nodelist = parse_nodelist(bits, parser) 39 | return BirdNode(name, attrs, nodelist, isolated_context) 40 | 41 | 42 | def parse_nodelist(bits: TagBits, parser: Parser) -> NodeList | None: 43 | # self-closing tag 44 | # {% bird name / %} 45 | if len(bits) > 0 and bits[-1] == "/": 46 | nodelist = None 47 | else: 48 | nodelist = parser.parse((END_TAG,)) 49 | parser.delete_first_token() 50 | return nodelist 51 | 52 | 53 | @final 54 | class BirdNode(template.Node): 55 | def __init__( 56 | self, 57 | name: str, 58 | attrs: TagBits, 59 | nodelist: NodeList | None, 60 | isolated_context: bool = False, 61 | ) -> None: 62 | self.name = name 63 | self.attrs = attrs 64 | self.nodelist = nodelist 65 | self.isolated_context = isolated_context 66 | 67 | @override 68 | def render(self, context: Context) -> str: 69 | from django_bird.components import components 70 | 71 | component_name = self.get_component_name(context) 72 | component = components.get_component(component_name) 73 | bound_component = component.get_bound_component(node=self) 74 | 75 | if self.isolated_context: 76 | return bound_component.render(context.new()) 77 | else: 78 | return bound_component.render(context) 79 | 80 | def get_component_name(self, context: Context) -> str: 81 | try: 82 | name = template.Variable(self.name).resolve(context) 83 | except template.VariableDoesNotExist: 84 | name = self.name 85 | return name 86 | -------------------------------------------------------------------------------- /src/django_bird/templatetags/tags/asset.py: -------------------------------------------------------------------------------- 1 | # pyright: reportAny=false 2 | from __future__ import annotations 3 | 4 | from enum import Enum 5 | from typing import final 6 | 7 | from django import template 8 | from django.conf import settings 9 | from django.template.base import Parser 10 | from django.template.base import Token 11 | from django.template.context import Context 12 | 13 | from django_bird._typing import override 14 | from django_bird.manifest import load_asset_manifest 15 | from django_bird.manifest import normalize_path 16 | 17 | 18 | class AssetTag(Enum): 19 | CSS = "bird:css" 20 | JS = "bird:js" 21 | 22 | 23 | def do_asset(_parser: Parser, token: Token) -> AssetNode: 24 | bits = token.split_contents() 25 | if len(bits) < 1: 26 | msg = "bird:assets tag requires at least one argument" 27 | raise template.TemplateSyntaxError(msg) 28 | tag_name = bits[0] 29 | asset_tag = AssetTag(tag_name) 30 | return AssetNode(asset_tag) 31 | 32 | 33 | @final 34 | class AssetNode(template.Node): 35 | def __init__(self, asset_tag: AssetTag): 36 | self.asset_tag = asset_tag 37 | 38 | @override 39 | def render(self, context: Context) -> str: 40 | from django_bird.components import components 41 | from django_bird.staticfiles import Asset 42 | from django_bird.staticfiles import get_component_assets 43 | 44 | template = getattr(context, "template", None) 45 | if not template: 46 | return "" 47 | 48 | template_path = template.origin.name 49 | 50 | used_components = [] 51 | 52 | # Only use manifest in production mode 53 | if not settings.DEBUG: 54 | manifest = load_asset_manifest() 55 | normalized_path = normalize_path(template_path) 56 | if manifest and normalized_path in manifest: 57 | component_names = manifest[normalized_path] 58 | used_components = [ 59 | components.get_component(name) for name in component_names 60 | ] 61 | 62 | # If we're in development or there was no manifest data, use registry 63 | if not used_components: 64 | used_components = list(components.get_component_usage(template_path)) 65 | 66 | assets: set[Asset] = set() 67 | for component in used_components: 68 | component_assets = get_component_assets(component) 69 | assets.update( 70 | asset for asset in component_assets if asset.type.tag == self.asset_tag 71 | ) 72 | 73 | if not assets: 74 | return "" 75 | 76 | rendered = [asset.render() for asset in sorted(assets, key=lambda a: a.path)] 77 | return "\n".join(rendered) 78 | -------------------------------------------------------------------------------- /src/django_bird/plugins/hookspecs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from collections.abc import Iterable 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from pluggy import HookspecMarker 9 | 10 | if TYPE_CHECKING: 11 | from django_bird.staticfiles import Asset 12 | from django_bird.staticfiles import AssetType 13 | 14 | hookspec = HookspecMarker("django_bird") 15 | 16 | 17 | @hookspec 18 | def collect_component_assets(template_path: Path) -> Iterable[Asset]: 19 | """Collect all assets associated with a component. 20 | 21 | This hook is called for each component template to gather its associated static assets. 22 | Implementations should scan for and return any CSS, JavaScript or other static files 23 | that belong to the component and return a list/set/other iterable of `Asset` objects. 24 | """ 25 | 26 | 27 | @hookspec 28 | def get_template_directories() -> list[Path]: 29 | """Return a list of all directories containing templates for a project. 30 | 31 | This hook allows plugins to provide additional template directories beyond the default 32 | Django template directories. Implementations should return a list of Path objects 33 | pointing to directories that contain Django templates. 34 | 35 | The template directories returned by this hook will be used by django-bird to discover 36 | components and their associated templates. 37 | """ 38 | 39 | 40 | @hookspec 41 | def pre_ready() -> None: 42 | """Called before django-bird begins its internal setup. 43 | 44 | This hook runs at the start of django-bird's initialization, before any internal 45 | components are configured or discovered. Plugins can use this hook to perform early 46 | configuration of: 47 | 48 | - Django settings 49 | - Template directories 50 | - Template builtins 51 | """ 52 | 53 | 54 | @hookspec 55 | def ready() -> None: 56 | """Called after django-bird application has completed its internal setup and is ready. 57 | 58 | This hook is called during Django's application ready phase, after django-bird's own 59 | initialization is complete. Plugins can: 60 | 61 | - Access fully configured components 62 | - Register additional features that depend on django-bird being ready 63 | - Perform cleanup or post-initialization tasks 64 | """ 65 | 66 | 67 | @hookspec 68 | def register_asset_types(register_type: Callable[[AssetType], None]) -> None: 69 | """Register a new type of asset. 70 | 71 | This hook allows plugins to register additional asset types beyond the default CSS 72 | and JS types. Each asset type defines how static assets should be rendered in HTML, 73 | what file extension it uses, and what django-bird asset templatetag it should be 74 | rendered with. 75 | """ 76 | -------------------------------------------------------------------------------- /tests/test_templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.test import override_settings 5 | 6 | from django_bird.templates import find_components_in_template 7 | from django_bird.templates import gather_bird_tag_template_usage 8 | from django_bird.templates import get_component_directory_names 9 | from django_bird.templates import get_template_names 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "name,component_dirs,expected", 14 | [ 15 | ( 16 | "button", 17 | [], 18 | [ 19 | "bird/button/button.html", 20 | "bird/button/index.html", 21 | "bird/button.html", 22 | ], 23 | ), 24 | ( 25 | "input.label", 26 | [], 27 | [ 28 | "bird/input/label/label.html", 29 | "bird/input/label/index.html", 30 | "bird/input/label.html", 31 | "bird/input.label.html", 32 | ], 33 | ), 34 | ( 35 | "button", 36 | ["custom", "theme"], 37 | [ 38 | "custom/button/button.html", 39 | "custom/button/index.html", 40 | "custom/button.html", 41 | "theme/button/button.html", 42 | "theme/button/index.html", 43 | "theme/button.html", 44 | "bird/button/button.html", 45 | "bird/button/index.html", 46 | "bird/button.html", 47 | ], 48 | ), 49 | ], 50 | ) 51 | def test_get_template_names(name, component_dirs, expected): 52 | with override_settings(DJANGO_BIRD={"COMPONENT_DIRS": component_dirs}): 53 | template_names = get_template_names(name) 54 | 55 | assert template_names == expected 56 | 57 | 58 | def test_get_template_names_invalid(): 59 | template_names = get_template_names("input.label") 60 | 61 | assert "bird/input/label/invalid.html" not in template_names 62 | 63 | 64 | def test_get_template_names_duplicates(override_app_settings): 65 | with override_app_settings(COMPONENT_DIRS=["bird"]): 66 | template_names = get_template_names("button") 67 | 68 | template_counts = {} 69 | for template in template_names: 70 | template_counts[template] = template_counts.get(template, 0) + 1 71 | 72 | for _, count in template_counts.items(): 73 | assert count == 1 74 | 75 | 76 | def test_component_directory_names(override_app_settings): 77 | assert get_component_directory_names() == ["bird"] 78 | 79 | with override_app_settings(COMPONENT_DIRS=["components"]): 80 | assert get_component_directory_names() == ["components", "bird"] 81 | 82 | 83 | def test_find_components_handles_errors(): 84 | result = find_components_in_template("non_existent_template.html") 85 | assert result == set() 86 | 87 | 88 | def test_find_components_handles_encoding_errors(templates_dir): 89 | binary_file = templates_dir / "binary_file.html" 90 | with open(binary_file, "wb") as f: 91 | f.write(b"\x80\x81\x82invalid binary content\xfe\xff") 92 | 93 | valid_file = templates_dir / "valid_file.html" 94 | valid_file.write_text(""" 95 | 96 | 97 | {% bird button %}Button{% endbird %} 98 | 99 | 100 | """) 101 | 102 | results = list(gather_bird_tag_template_usage()) 103 | 104 | assert all(str(valid_file) in str(path) for path, _ in results) 105 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from dataclasses import field 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from django_bird.staticfiles import AssetType 10 | 11 | 12 | @dataclass 13 | class TestComponent: 14 | __test__ = False 15 | 16 | name: str 17 | content: str 18 | file: Path | None = None 19 | parent_dir: str = "bird" 20 | sub_dir: str | None = None 21 | 22 | def create(self, base_dir: Path) -> TestComponent: 23 | parent = base_dir / self.parent_dir 24 | parent.mkdir(exist_ok=True) 25 | 26 | if self.sub_dir is not None: 27 | dir = parent / self.sub_dir 28 | dir.mkdir(exist_ok=True) 29 | else: 30 | dir = parent 31 | 32 | template = dir / f"{self.name}.html" 33 | template.write_text(self.content) 34 | 35 | self.file = template 36 | 37 | return self 38 | 39 | 40 | @dataclass 41 | class TestAsset: 42 | __test__ = False 43 | 44 | component: TestComponent 45 | content: str 46 | asset_type: AssetType 47 | file: Path | None = None 48 | 49 | def create(self) -> TestAsset: 50 | if self.component.file is None: 51 | raise ValueError("Component must be created before adding assets") 52 | 53 | component_dir = self.component.file.parent 54 | component_name = self.component.file.stem 55 | 56 | asset_file = component_dir / f"{component_name}.{self.asset_type.extension}" 57 | asset_file.write_text(self.content) 58 | 59 | self.file = asset_file 60 | 61 | return self 62 | 63 | @property 64 | def relative_file_path(self): 65 | component_dir = Path(self.component.parent_dir) 66 | if self.component.sub_dir: 67 | component_dir = component_dir / self.component.sub_dir 68 | return Path(component_dir / self.file.name) 69 | 70 | 71 | @dataclass 72 | class TestComponentCase: 73 | __test__ = False 74 | 75 | component: TestComponent 76 | template_content: str 77 | expected: str 78 | description: str = "" 79 | template_context: dict[str, Any] = field(default_factory=dict) 80 | 81 | 82 | def print_directory_tree(root_dir: str | Path, prefix: str = ""): 83 | root_path = Path(root_dir) 84 | contents = sorted(root_path.iterdir()) 85 | pointers = ["├── "] * (len(contents) - 1) + ["└── "] 86 | for pointer, path in zip(pointers, contents, strict=False): 87 | print(prefix + pointer + path.name) 88 | if path.is_dir(): 89 | extension = "│ " if pointer == "├── " else " " 90 | print_directory_tree(path, prefix=prefix + extension) 91 | 92 | 93 | def normalize_whitespace(text: str) -> str: 94 | """Normalize whitespace in rendered template output""" 95 | # multiple whitespace characters 96 | text = re.sub(r"\s+", " ", text) 97 | # after opening tag, including when there are attributes 98 | text = re.sub(r"<(\w+)(\s+[^>]*)?\s*>", r"<\1\2>", text) 99 | # before closing tag 100 | text = re.sub(r"\s+>", ">", text) 101 | # after opening tag and before closing tag 102 | text = re.sub(r">\s+<", "><", text) 103 | # immediately after opening tag (including attributes) or before closing tag 104 | text = re.sub(r"(<\w+(?:\s+[^>]*)?>)\s+|\s+(<\/\w+>)", r"\1\2", text) 105 | # between tags and text content 106 | text = re.sub(r">\s+([^<])", r">\1", text) 107 | text = re.sub(r"([^>])\s+<", r"\1<", text) 108 | return text.strip() 109 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing a New Version 2 | 3 | When it comes time to cut a new release, follow these steps: 4 | 5 | 1. Create a new git branch off of `main` for the release. 6 | 7 | Prefer the convention `release-`, where `` is the next incremental version number (e.g. `release-v0.1.0` for version 0.1.0). 8 | 9 | ```shell 10 | git checkout -b release-v 11 | ``` 12 | 13 | However, the branch name is not *super* important, as long as it is not `main`. 14 | 15 | 2. Update the version number across the project using the `bumpver` tool. See [this section](#choosing-the-next-version-number) for more details about choosing the correct version number. 16 | 17 | The `pyproject.toml` in the base of the repository contains a `[tool.bumpver]` section that configures the `bumpver` tool to update the version number wherever it needs to be updated and to create a commit with the appropriate commit message. 18 | 19 | `bumpver` is included as a development dependency, so you should already have it installed if you have installed the development dependencies for this project. If you do not have the development dependencies installed, you can install them with either of the following commands: 20 | 21 | ```shell 22 | python -m pip install --editable '.[dev]' 23 | # or using [just](CONTRIBUTING.md#just) 24 | just bootstrap 25 | ``` 26 | 27 | Then, run `bumpver` to update the version number, with the appropriate command line arguments. See the [`bumpver` documentation](https://github.com/mbarkhau/bumpver) for more details. 28 | 29 | **Note**: For any of the following commands, you can add the command line flag `--dry` to preview the changes without actually making the changes. 30 | 31 | Here are the most common commands you will need to run: 32 | 33 | ```shell 34 | bumpver update --patch # for a patch release 35 | bumpver update --minor # for a minor release 36 | bumpver update --major # for a major release 37 | ``` 38 | 39 | To release a tagged version, such as a beta or release candidate, you can run: 40 | 41 | ```shell 42 | bumpver update --tag=beta 43 | # or 44 | bumpver update --tag=rc 45 | ``` 46 | 47 | Running these commands on a tagged version will increment the tag appropriately, but will not increment the version number. 48 | 49 | To go from a tagged release to a full release, you can run: 50 | 51 | ```shell 52 | bumpver update --tag=final 53 | ``` 54 | 55 | 3. Ensure the [CHANGELOG](CHANGELOG.md) is up to date. If updates are needed, add them now in the release branch. 56 | 57 | 4. Create a pull request from the release branch to `main`. 58 | 59 | 5. Once CI has passed and all the checks are green ✅, merge the pull request. 60 | 61 | 6. Draft a [new release](https://github.com/joshuadavidthomas/django-bird/releases/new) on GitHub. 62 | 63 | Use the version number with a leading `v` as the tag name (e.g. `v0.1.0`). 64 | 65 | Allow GitHub to generate the release title and release notes, using the 'Generate release notes' button above the text box. If this is a final release coming from a tagged release (or multiple tagged releases), make sure to copy the release notes from the previous tagged release(s) to the new release notes (after the release notes already generated for this final release). 66 | 67 | If this is a tagged release, make sure to check the 'Set as a pre-release' checkbox. 68 | 69 | 7. Once you are satisfied with the release, publish the release. As part of the publication process, GitHub Actions will automatically publish the new version of the package to PyPI. 70 | 71 | ## Choosing the Next Version Number 72 | 73 | We try our best to adhere to [Semantic Versioning](https://semver.org/), but we do not promise to follow it perfectly (and let's be honest, this is the case with a lot of projects using SemVer). 74 | 75 | In general, use your best judgement when choosing the next version number. If you are unsure, you can always ask for a second opinion from another contributor. 76 | -------------------------------------------------------------------------------- /src/django_bird/params.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from dataclasses import field 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | 8 | from django import template 9 | from django.template.context import Context 10 | from django.utils.safestring import SafeString 11 | from django.utils.safestring import mark_safe 12 | 13 | from .templatetags.tags.bird import BirdNode 14 | from .templatetags.tags.prop import PropNode 15 | 16 | if TYPE_CHECKING: 17 | from django_bird.components import Component 18 | 19 | 20 | @dataclass 21 | class Params: 22 | attrs: list[Param] = field(default_factory=list) 23 | props: list[Param] = field(default_factory=list) 24 | 25 | def render_props(self, component: Component, context: Context): 26 | if component.nodelist is None: 27 | return 28 | 29 | attrs_to_remove = set() 30 | 31 | for node in component.nodelist: 32 | if not isinstance(node, PropNode): 33 | continue 34 | 35 | value = Value(node.default) 36 | 37 | for idx, attr in enumerate(self.attrs): 38 | if node.name == attr.name: 39 | resolved = attr.value.resolve(context) 40 | if resolved is not None: 41 | value = attr.value 42 | attrs_to_remove.add(idx) 43 | 44 | self.props.append(Param(name=node.name, value=value)) 45 | 46 | for idx in sorted(attrs_to_remove, reverse=True): 47 | self.attrs.pop(idx) 48 | 49 | return {prop.name: prop.render_prop(context) for prop in self.props} 50 | 51 | def render_attrs(self, context: Context) -> SafeString: 52 | rendered = " ".join(attr.render_attr(context) for attr in self.attrs) 53 | return mark_safe(rendered) 54 | 55 | @classmethod 56 | def from_node(cls, node: BirdNode) -> Params: 57 | return cls( 58 | attrs=[Param.from_bit(bit) for bit in node.attrs], 59 | props=[], 60 | ) 61 | 62 | 63 | @dataclass 64 | class Param: 65 | name: str 66 | value: Value 67 | 68 | def render_attr(self, context: Context) -> str: 69 | value = self.value.resolve(context) 70 | if value is None: 71 | return "" 72 | name = self.name.replace("_", "-") 73 | if value is True: 74 | return name 75 | return f'{name}="{value}"' 76 | 77 | def render_prop(self, context: Context) -> str | bool | None: 78 | return self.value.resolve(context) 79 | 80 | @classmethod 81 | def from_bit(cls, bit: str) -> Param: 82 | if "=" in bit: 83 | name, raw_value = bit.split("=", 1) 84 | value = Value(raw_value.strip()) 85 | else: 86 | name, value = bit, Value(True) 87 | return cls(name, value) 88 | 89 | 90 | @dataclass 91 | class Value: 92 | raw: str | bool | None 93 | 94 | def resolve(self, context: Context | dict[str, Any]) -> Any: 95 | match (self.raw, self.is_quoted): 96 | case (None, _): 97 | return None 98 | 99 | case (str(raw_str), False) if raw_str == "False": 100 | return None 101 | case (str(raw_str), False) if raw_str == "True": 102 | return True 103 | 104 | case (bool(b), _): 105 | return b if b else None 106 | 107 | case (str(raw_str), False): 108 | try: 109 | return template.Variable(raw_str).resolve(context) 110 | except template.VariableDoesNotExist: 111 | return raw_str 112 | 113 | case (_, True): 114 | return str(self.raw)[1:-1] 115 | 116 | @property 117 | def is_quoted(self) -> bool: 118 | if self.raw is None or isinstance(self.raw, bool): 119 | return False 120 | 121 | return self.raw.startswith(("'", '"')) and self.raw.endswith(self.raw[0]) 122 | -------------------------------------------------------------------------------- /.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 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # uv 113 | uv.lock 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | # Logs 166 | logs 167 | *.log 168 | npm-debug.log* 169 | yarn-debug.log* 170 | yarn-error.log* 171 | pnpm-debug.log* 172 | lerna-debug.log* 173 | 174 | node_modules 175 | dist 176 | dist-ssr 177 | *.local 178 | 179 | # Editor directories and files 180 | .vscode/* 181 | !.vscode/extensions.json 182 | !.vscode/*.example 183 | .idea 184 | .DS_Store 185 | *.suo 186 | *.ntvs* 187 | *.njsproj 188 | *.sln 189 | *.sw? 190 | 191 | staticfiles/ 192 | mediafiles/ 193 | 194 | # pyright config for nvim-lspconfig 195 | pyrightconfig.json 196 | 197 | # Sphinx apidocs 198 | docs/apidocs/ 199 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import sys 11 | 12 | # import django 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Django setup ----------------------------------------------------------- 24 | # This is required to import Django code in Sphinx using autodoc. 25 | 26 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 27 | # django.setup() 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "django-bird" 33 | copyright = "2024, Josh Thomas" 34 | author = "Josh Thomas" 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "autodoc2", 44 | "myst_parser", 45 | "sphinx_copybutton", 46 | "sphinx_inline_tabs", 47 | "sphinx.ext.napoleon", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = [] 57 | 58 | 59 | # -- MyST configuration ------------------------------------------------------ 60 | myst_heading_anchors = 3 61 | 62 | # -- Options for autodoc2 ----------------------------------------------------- 63 | autodoc2_docstring_parser_regexes = [ 64 | (r".*", "myst"), 65 | ] 66 | autodoc2_packages = [f"../src/{project.replace('-', '_')}"] 67 | autodoc2_render_plugin = "myst" 68 | 69 | # -- Options for sphinx_copybutton ----------------------------------------------------- 70 | copybutton_selector = "div.copy pre" 71 | copybutton_prompt_text = "$ " 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | html_theme = "furo" 79 | 80 | # Add any paths that contain custom static files (such as style sheets) here, 81 | # relative to this directory. They are copied after the builtin static files, 82 | # so a file named "default.css" will overwrite the builtin "default.css". 83 | html_static_path = ["_static"] 84 | 85 | html_css_files = [ 86 | "css/custom.css", 87 | ] 88 | 89 | html_title = project 90 | 91 | html_theme_options = { 92 | "footer_icons": [ 93 | { 94 | "name": "GitHub", 95 | "url": "https://github.com/joshuadavidthomas/django-bird", 96 | "html": """ 97 | 98 | 99 | 100 | """, 101 | "class": "", 102 | }, 103 | ], 104 | } 105 | 106 | html_sidebars = { 107 | "**": [ 108 | "sidebar/brand.html", 109 | "sidebar/search.html", 110 | "sidebar/scroll-start.html", 111 | "sidebar/navigation.html", 112 | "sidebar/scroll-end.html", 113 | "sidebar/variant-selector.html", 114 | ] 115 | } 116 | -------------------------------------------------------------------------------- /docs/vars.md: -------------------------------------------------------------------------------- 1 | # Variables in Components 2 | 3 | django-bird provides a way to manage local variables within components using the `{% bird:var %}` template tag. Similar to Django's built-in `{% with %}` tag, it allows you to create temporary variables, but with some key advantages: 4 | 5 | - No closing tag required (unlike `{% with %}` which needs `{% endwith %}`) 6 | - Variables are automatically cleaned up when the component finishes rendering 7 | - Variables are properly scoped to each component instance 8 | - Supports appending to existing values 9 | 10 | ## Basic Usage 11 | 12 | The `{% bird:var %}` tag allows you to create and modify variables that are scoped to the current component. These variables are accessible through the `vars` context dictionary. 13 | 14 | ### Creating Variables 15 | 16 | To create a new variable, use the assignment syntax: 17 | 18 | ```htmldjango 19 | {% bird:var name='value' %} 20 | 21 | {{ vars.name }} {# Outputs: value #} 22 | ``` 23 | 24 | ### Overwriting and Clearing Variables 25 | 26 | You can overwrite an existing variable by assigning a new value: 27 | 28 | ```htmldjango 29 | {% bird:var counter='1' %} 30 | 31 | {{ vars.counter }} {# Outputs: 1 #} 32 | 33 | {% bird:var counter='2' %} 34 | 35 | {{ vars.counter }} {# Outputs: 2 #} 36 | ``` 37 | 38 | To reset/clear a variable, set it to None: 39 | 40 | ```htmldjango 41 | {% bird:var message='hello' %} 42 | 43 | {{ vars.message }} {# Outputs: hello #} 44 | 45 | {% bird:var message=None %} 46 | 47 | {{ vars.message }} {# Variable is cleared #} 48 | ``` 49 | 50 | Alternatively, you can use explicit cleanup with `{% endbird:var %}` - see [Explicit Variable Cleanup](#explicit-variable-cleanup) for details. 51 | 52 | ### Appending to Variables 53 | 54 | The `+=` operator lets you append to existing variables: 55 | 56 | ```htmldjango 57 | {% bird:var greeting='Hello' %} 58 | {% bird:var greeting+=' World' %} 59 | 60 | {{ vars.greeting }} {# Outputs: Hello World #} 61 | ``` 62 | 63 | If you append to a non-existent variable, it will be created: 64 | 65 | ```htmldjango 66 | {% bird:var message+='World' %} 67 | 68 | {{ vars.message }} {# Outputs: World #} 69 | ``` 70 | 71 | ## Variable Scope 72 | 73 | Variables created with `{% bird:var %}` are: 74 | 75 | - Local to the component where they are defined 76 | - Isolated between different instances of the same component 77 | - Not accessible outside the component 78 | - Reset between renders 79 | 80 | ```{code-block} htmldjango 81 | :caption: templates/bird/button.html 82 | 83 | {% bird:var count='1' %} 84 | 85 | Count: {{ vars.count }} 86 | ``` 87 | 88 | ```{code-block} htmldjango 89 | :caption: template.html 90 | 91 | {% bird button %}{% endbird %} {# Count: 1 #} 92 | {% bird button %}{% endbird %} {# Count: 1 #} 93 | 94 | Outside: {{ vars.count }} {# vars.count is not accessible here #} 95 | ``` 96 | 97 | Each instance of the button component will have its own isolated `count` variable. 98 | 99 | ### Explicit Variable Cleanup 100 | 101 | While variables are automatically cleaned up when a component finishes rendering, you can explicitly clean up variables using the `{% endbird:var %}` tag: 102 | 103 | ```htmldjango 104 | {% bird:var message='Hello' %} 105 | 106 | {{ vars.message }} {# Outputs: Hello #} 107 | 108 | {% endbird:var message %} 109 | 110 | {{ vars.message }} {# Variable is now cleaned up #} 111 | ``` 112 | 113 | This can be useful when you want to ensure a variable is cleaned up at a specific point in your template, rather than waiting for the component to finish rendering. 114 | 115 | You can clean up multiple variables independently: 116 | 117 | ```htmldjango 118 | {% bird:var x='hello' %} 119 | {% bird:var y='world' %} 120 | 121 | {{ vars.x }} {{ vars.y }} {# Outputs: hello world #} 122 | 123 | {% endbird:var x %} 124 | 125 | {{ vars.x }} {{ vars.y }} {# Outputs: world (x is cleaned up) #} 126 | 127 | {% endbird:var y %} 128 | 129 | {{ vars.x }} {{ vars.y }} {# Both variables are now cleaned up #} 130 | ``` 131 | 132 | ## Working with Template Variables 133 | 134 | You can use template variables when setting values: 135 | 136 | ```htmldjango 137 | {% bird:var greeting='Hello ' %} 138 | {% bird:var greeting+=user.name %} 139 | 140 | {{ vars.greeting }} {# Outputs: Hello John #} 141 | ``` 142 | 143 | ## Nested Components 144 | 145 | Variables are properly scoped in nested components: 146 | 147 | ```{code-block} htmldjango 148 | :caption: templates/bird/outer.html 149 | 150 | {% bird:var message='Outer' %} 151 | 152 | {{ vars.message }} 153 | 154 | {% bird inner %}{% endbird %} 155 | 156 | {{ vars.message }} {# vars.message still contains 'Outer' here #} 157 | ``` 158 | 159 | ```{code-block} htmldjango 160 | :caption: templates/bird/inner.html 161 | 162 | {% bird:var message='Inner' %} 163 | 164 | {{ vars.message }} {# Contains 'Inner', doesn't affect outer component #} 165 | ``` 166 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Django settings 4 | 5 | To use django-bird, you need to configure a few settings in your project: 6 | 7 | 1. Add django-bird's static file finder to your STATICFILES_FINDERS. 8 | 2. Add django-bird's template tags to Django's built-ins. **Note**: This is not required, but if you do not do this you will need to use `{% load django_bird %}` in any templates using components. 9 | 10 | ```{admonition} Auto Configuration 11 | :class: tip 12 | 13 | For automatic configuration of these settings, you can use the [django-bird-autoconf](https://pypi.org/project/django-bird-autoconf/) plugin. This plugin will handle all the setup for you automatically. 14 | ``` 15 | 16 | The complete setup in your settings file should look like this: 17 | 18 | ```{code-block} python 19 | :caption: settings.py 20 | 21 | from pathlib import Path 22 | 23 | BASE_DIR = Path(__file__).resolve(strict=True).parent 24 | 25 | TEMPLATES = [ 26 | { 27 | "BACKEND": "django.template.backends.django.DjangoTemplates", 28 | "DIRS": [ 29 | BASE_DIR / "templates", 30 | ], 31 | "OPTIONS": { 32 | "builtins": [ 33 | "django_bird.templatetags.django_bird", 34 | ], 35 | }, 36 | } 37 | ] 38 | 39 | STATICFILES_FINDERS = [ 40 | "django.contrib.staticfiles.finders.FileSystemFinder", 41 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 42 | "django_bird.staticfiles.BirdAssetFinder", 43 | ] 44 | ``` 45 | 46 | This configuration ensures that django-bird's templatetags are available globally and component assets can be properly discovered. 47 | Configuration of django-bird is done through a `DJANGO_BIRD` dictionary in your Django settings. 48 | 49 | ## Application settings 50 | 51 | All app settings are optional. Here is an example configuration with the types and default values shown: 52 | 53 | ```{code-block} python 54 | :caption: settings.py 55 | 56 | from pathlib import Path 57 | 58 | DJANGO_BIRD = { 59 | "COMPONENT_DIRS": list[Path | str] = [], 60 | "ENABLE_BIRD_ATTRS": bool = True, 61 | "ADD_ASSET_PREFIX": bool | None = None, 62 | } 63 | ``` 64 | 65 | ### `COMPONENT_DIRS` 66 | 67 | Additional directories to scan for components. Takes a list of paths relative to the base directory of your project's templates directory. A path can either be a `str` or `Path`. 68 | 69 | By default, django-bird will look for components in a `bird` directory. Any directories specified here will take precedence and take priority when performing template resolution for components. 70 | 71 | #### Example 72 | 73 | Suppose you want to store your components in a `components` directory, you're using a third-party library that provides its own bird components, and you have an alternate templates directory. 74 | 75 | You can configure django-bird to look in all these locations: 76 | 77 | ```{code-block} python 78 | :caption: settings.py 79 | 80 | from pathlib import Path 81 | 82 | BASE_DIR = Path(__file__).resolve(strict=True).parent 83 | 84 | DJANGO_BIRD = { 85 | "COMPONENT_DIRS": [ 86 | "components", 87 | Path("third_party_library/components"), 88 | BASE_DIR / "alternate_templates" / "bird", 89 | ] 90 | } 91 | ``` 92 | 93 | In this configuration: 94 | 95 | - `"components"` is a string path relative to your project's templates directory. 96 | - `Path("third_party_library/components")` uses the `Path` object for the third-party library's components. 97 | - `BASE_DIR / "alternate_templates" / "bird"` constructs a path using Django's `BASE_DIR` setting, similar to how other Django settings can be configured. 98 | 99 | With this setup, django-bird will search for components in the following order: 100 | 101 | 1. `components` 102 | 2. `third_party_library/components` 103 | 3. `alternate_templates/bird` 104 | 4. The default `bird` directory 105 | 106 | The default `bird` directory will always be checked last, ensuring that your custom directories take precedence in template resolution. 107 | 108 | 109 | ### `ENABLE_BIRD_ATTRS` 110 | 111 | Controls whether components automatically receive data attributes related to django-bird in its `attrs` template context variable. Defaults to `True`. 112 | 113 | See [Component ID Attribute](params.md#component-id-attribute) for more details on how this works. 114 | 115 | ### `ADD_ASSET_PREFIX` 116 | 117 | Controls whether the app label prefix (`django_bird/`) is added to component asset URLs. This setting has three possible values: 118 | 119 | - `None` (default): Automatically add the prefix in production (when `DEBUG = False`) but not in development mode. This matches Django's standard behavior where staticfiles are served directly from source directories in development but collected into a central location in production. 120 | 121 | - `True`: Always add the prefix, regardless of the `DEBUG` setting. This is useful if you want consistent URL paths in all environments. 122 | 123 | - `False`: Never add the prefix, regardless of the `DEBUG` setting. This is useful for custom static file configurations or when you're manually managing the directory structure. 124 | 125 | #### Example Use Cases 126 | 127 | - **Testing Environment**: In test environments, especially with Playwright or Selenium e2e tests, you may want to set: 128 | 129 | ```python 130 | DJANGO_BIRD = {"ADD_ASSET_PREFIX": False} 131 | ``` 132 | 133 | This ensures your tests can find static assets without the prefix, even when `DEBUG = False`. 134 | 135 | - **Custom Static File Handling**: If you have a custom static file setup that doesn't follow Django's conventions, you can configure the appropriate value based on your needs. 136 | -------------------------------------------------------------------------------- /src/django_bird/manifest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import json 5 | import logging 6 | from enum import Enum 7 | from pathlib import Path 8 | 9 | from django.conf import settings 10 | 11 | from django_bird.templates import gather_bird_tag_template_usage 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | _manifest_cache = None 16 | 17 | 18 | class PathPrefix(str, Enum): 19 | """Path prefixes used for normalizing template paths.""" 20 | 21 | PKG = "pkg" 22 | APP = "app" 23 | EXT = "ext" 24 | 25 | def prepend_to(self, path: str) -> str: 26 | """Generate a prefixed path string by prepending this prefix to a path. 27 | 28 | Args: 29 | path: The path to prefix 30 | 31 | Returns: 32 | str: A string with this prefix and the path 33 | """ 34 | return f"{self.value}:{path}" 35 | 36 | @classmethod 37 | def has_prefix(cls, path: str) -> bool: 38 | """Check if a path already has one of the recognized prefixes. 39 | 40 | Args: 41 | path: The path to check 42 | 43 | Returns: 44 | bool: True if the path starts with any of the recognized prefixes 45 | """ 46 | return any(path.startswith(f"{prefix.value}:") for prefix in cls) 47 | 48 | 49 | def normalize_path(path: str) -> str: 50 | """Normalize a template path to remove system-specific information. 51 | 52 | Args: 53 | path: The template path to normalize 54 | 55 | Returns: 56 | str: A normalized path without system-specific details 57 | """ 58 | if PathPrefix.has_prefix(path): 59 | return path 60 | 61 | if "site-packages" in path: 62 | parts = path.split("site-packages/") 63 | if len(parts) > 1: 64 | return PathPrefix.PKG.prepend_to(parts[1]) 65 | 66 | if hasattr(settings, "BASE_DIR") and settings.BASE_DIR: # type: ignore[misc] 67 | base_dir = Path(settings.BASE_DIR).resolve() # type: ignore[misc] 68 | abs_path = Path(path).resolve() 69 | try: 70 | if str(abs_path).startswith(str(base_dir)): 71 | rel_path = abs_path.relative_to(base_dir) 72 | return PathPrefix.APP.prepend_to(str(rel_path)) 73 | except ValueError: 74 | # Path is not relative to BASE_DIR 75 | pass 76 | 77 | if path.startswith("/"): 78 | hash_val = hashlib.md5(path.encode()).hexdigest()[:8] 79 | filename = Path(path).name 80 | return PathPrefix.EXT.prepend_to(f"{hash_val}/{filename}") 81 | 82 | # Return as is if it's already a relative path 83 | return path 84 | 85 | 86 | def load_asset_manifest() -> dict[str, list[str]] | None: 87 | """Load asset manifest from the default location. 88 | 89 | Returns a simple dict mapping template paths to lists of component names. 90 | If the manifest cannot be loaded, returns None and falls back to runtime scanning. 91 | 92 | Returns: 93 | dict[str, list[str]] | None: Manifest data or None if not found or invalid 94 | """ 95 | global _manifest_cache 96 | 97 | if _manifest_cache is not None: 98 | return _manifest_cache 99 | 100 | if hasattr(settings, "STATIC_ROOT") and settings.STATIC_ROOT: 101 | manifest_path = default_manifest_path() 102 | if manifest_path.exists(): 103 | try: 104 | with open(manifest_path) as f: 105 | manifest_data = json.load(f) 106 | _manifest_cache = manifest_data 107 | return manifest_data 108 | except json.JSONDecodeError: 109 | logger.warning( 110 | f"Asset manifest at {manifest_path} contains invalid JSON. Falling back to registry." 111 | ) 112 | return None 113 | except (OSError, PermissionError) as e: 114 | logger.warning( 115 | f"Error reading asset manifest at {manifest_path}: {str(e)}. Falling back to registry." 116 | ) 117 | return None 118 | 119 | # No manifest found, will fall back to registry 120 | return None 121 | 122 | 123 | def generate_asset_manifest() -> dict[str, list[str]]: 124 | """Generate a manifest by scanning templates for component usage. 125 | 126 | Returns: 127 | dict[str, list[str]]: A dictionary mapping template paths to lists of component names. 128 | """ 129 | template_component_map: dict[str, set[str]] = {} 130 | 131 | for template_path, component_names in gather_bird_tag_template_usage(): 132 | # Convert Path objects to strings for JSON and normalize 133 | original_path = str(template_path) 134 | normalized_path = normalize_path(original_path) 135 | template_component_map[normalized_path] = component_names 136 | 137 | manifest: dict[str, list[str]] = { 138 | template: sorted(list(components)) 139 | for template, components in template_component_map.items() 140 | } 141 | 142 | return manifest 143 | 144 | 145 | def save_asset_manifest(manifest_data: dict[str, list[str]], path: Path | str) -> None: 146 | """Save asset manifest to a file. 147 | 148 | Args: 149 | manifest_data: The manifest data to save 150 | path: Path where to save the manifest 151 | """ 152 | path_obj = Path(path) 153 | path_obj.parent.mkdir(parents=True, exist_ok=True) 154 | 155 | with open(path_obj, "w") as f: 156 | json.dump(manifest_data, f, indent=2) 157 | 158 | 159 | def default_manifest_path() -> Path: 160 | """Get the default manifest path. 161 | 162 | Returns: 163 | Path: The default path for the asset manifest file 164 | """ 165 | if hasattr(settings, "STATIC_ROOT") and settings.STATIC_ROOT: 166 | return Path(settings.STATIC_ROOT) / "django_bird" / "manifest.json" 167 | else: 168 | # Fallback for when STATIC_ROOT is not set 169 | return Path("django_bird-asset-manifest.json") 170 | -------------------------------------------------------------------------------- /docs/assets.md: -------------------------------------------------------------------------------- 1 | # CSS and JavaScript Assets 2 | 3 | django-bird automatically discovers and manages CSS and JavaScript assets for your components. 4 | 5 | ## Asset Discovery 6 | 7 | Assets are discovered based on file names matching your component template: 8 | 9 | ```bash 10 | templates/bird/ 11 | ├── button.css 12 | ├── button.html 13 | └── button.js 14 | ``` 15 | 16 | The library looks for `.css` and `.js` files with the same name as your component template. 17 | 18 | You can also organize components in their own directories. The library will find assets as long as they match the component name and are in the same directory as the template: 19 | 20 | ```bash 21 | templates/bird/button/ 22 | ├── button.css 23 | ├── button.html 24 | └── button.js 25 | ``` 26 | 27 | This organization can be particularly useful when components have multiple related files or when you want to keep component code isolated. See [Template Resolution](naming.md#template-resolution) for more information and [Organization](organization.md) for different ways to structure your components and their assets. 28 | 29 | ## Using Assets 30 | 31 | django-bird provides two templatetags for automatically loading your CSS and Javascript assets into your project's templates: 32 | 33 | - `{% bird:css %}` 34 | - `{% bird:js %}` 35 | 36 | To include component assets in your templates, add the templatetags to your base template: 37 | 38 | ```htmldjango 39 | 40 | 41 | 42 | {% bird:css %} {# Includes CSS from all components used in template #} 43 | 44 | 45 | {% block content %}{% endblock %} 46 | {% bird:js %} {# Includes JavaScript from all components used in template #} 47 | 48 | 49 | ``` 50 | 51 | The asset tags will automatically: 52 | 53 | - Find all components used in the template (including extends and includes) 54 | - Collect their associated assets 55 | - Output the appropriate HTML tags 56 | 57 | For example, if your template uses components with associated assets: 58 | 59 | ```htmldjango 60 | {% bird button %}Click me{% endbird %} 61 | {% bird alert %}Warning!{% endbird %} 62 | ``` 63 | 64 | The asset tags will render: 65 | 66 | ```html 67 | {# {% bird:css %} renders: #} 68 | 69 | 70 | 71 | {# {% bird:js %} renders: #} 72 | 73 | 74 | ``` 75 | 76 | Assets are automatically deduplicated, so each component's assets are included only once even if the component is used multiple times in your templates. Only assets from components actually used in the template (or its parent templates) will be included - unused components' assets won't be loaded, keeping your pages lean. 77 | 78 | ## Template Inheritance 79 | 80 | Assets are collected from all components used in your template hierarchy: 81 | 82 | ```{code-block} htmldjango 83 | :caption: base.html 84 | 85 | 86 | 87 | 88 | {% bird:css %} {# Gets CSS from both nav and content components #} 89 | 90 | 91 | {% bird nav %}{% endbird %} 92 | {% block content %}{% endblock %} 93 | {% bird:js %} 94 | 95 | 96 | ``` 97 | 98 | ```{code-block} htmldjango 99 | :caption: page.html 100 | 101 | {% extends "base.html" %} 102 | 103 | {% block content %} 104 | {% bird content %} 105 | Page content here 106 | {% endbird %} 107 | {% endblock %} 108 | ``` 109 | 110 | The `{% bird:css %}` tag will include CSS and the `[% bird:js %}` tag will include JavaScript from both the `nav` and `content` components. 111 | 112 | ## Serving Assets 113 | 114 | ### Using the Staticfiles Finder 115 | 116 | django-bird provides a custom staticfiles finder to serve component assets through Django's static files system. This allows you to collect all component assets using Django's `collectstatic` command and serve them efficiently in production. 117 | 118 | To enable the custom finder, `BirdAssetFinder` must be in the list in your `STATICFILES_FINDERS` setting. If you aren't using the [django-bird-autoconf](https://pypi.org/project/django-bird-autoconf/) plugin, you will need to add the finder manually: 119 | 120 | ```{code-block} python 121 | :caption: settings.py 122 | 123 | STATICFILES_FINDERS = [ 124 | # ... your existing finders ... 125 | "django_bird.staticfiles.BirdAssetFinder", 126 | ] 127 | ``` 128 | 129 | After adding the finder, run: 130 | 131 | ```bash 132 | python manage.py collectstatic 133 | ``` 134 | 135 | This will collect all component assets into your static files directory, allowing you to serve them via your web server, [WhiteNoise](https://whitenoise.readthedocs.io), or a CDN. 136 | 137 | ## Asset Manifest 138 | 139 | For production deployments, django-bird provides a management command to generate an asset manifest: 140 | 141 | ```bash 142 | python manage.py generate_asset_manifest 143 | ``` 144 | 145 | This command creates a manifest file at `STATIC_ROOT/django_bird/manifest.json` that maps templates to their used components. In production mode, this manifest is used to load assets without scanning templates at runtime. 146 | 147 | ### Integration with collectstatic 148 | 149 | For optimal deployment, follow this sequence: 150 | 151 | 1. Run `python manage.py collectstatic` first to collect all component assets 152 | 2. Then run `python manage.py generate_asset_manifest` to create the manifest file in the collected static files 153 | 154 | This ensures that: 155 | - All component assets are properly collected by the Django staticfiles system 156 | - The manifest is generated with up-to-date component information 157 | - The manifest file is placed in the correct location within your static files directory 158 | 159 | For automated deployments, you can combine these commands: 160 | 161 | ```bash 162 | python manage.py collectstatic --noinput && python manage.py generate_asset_manifest 163 | ``` 164 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | 7 | import nox 8 | 9 | nox.options.default_venv_backend = "uv|virtualenv" 10 | nox.options.reuse_existing_virtualenvs = True 11 | 12 | PY310 = "3.10" 13 | PY311 = "3.11" 14 | PY312 = "3.12" 15 | PY313 = "3.13" 16 | PY_VERSIONS = [PY310, PY311, PY312, PY313] 17 | PY_DEFAULT = PY_VERSIONS[0] 18 | PY_LATEST = PY_VERSIONS[-1] 19 | 20 | DJ42 = "4.2" 21 | DJ50 = "5.0" 22 | DJ51 = "5.1" 23 | DJ52 = "5.2b1" 24 | DJMAIN = "main" 25 | DJMAIN_MIN_PY = PY312 26 | DJ_VERSIONS = [DJ42, DJ50, DJ51, DJ52, DJMAIN] 27 | DJ_LTS = [ 28 | version for version in DJ_VERSIONS if version.endswith(".2") and version != DJMAIN 29 | ] 30 | DJ_DEFAULT = DJ_LTS[0] 31 | DJ_LATEST = DJ_VERSIONS[-2] 32 | 33 | 34 | def version(ver: str) -> tuple[int, ...]: 35 | """Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)""" 36 | return tuple(map(int, ver.split("."))) 37 | 38 | 39 | def should_skip(python: str, django: str) -> bool: 40 | """Return True if the test should be skipped""" 41 | 42 | if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY): 43 | # Django main requires Python 3.10+ 44 | return True 45 | 46 | if django == DJ52 and version(python) < version(PY310): 47 | # Django 5.2a1 requires Python 3.10+ 48 | return True 49 | 50 | if django == DJ51 and version(python) < version(PY310): 51 | # Django 5.1 requires Python 3.10+ 52 | return True 53 | 54 | if django == DJ50 and version(python) < version(PY310): 55 | # Django 5.0 requires Python 3.10+ 56 | return True 57 | 58 | return False 59 | 60 | 61 | @nox.session 62 | def test(session): 63 | session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')") 64 | 65 | 66 | @nox.session 67 | @nox.parametrize( 68 | "python,django", 69 | [ 70 | (python, django) 71 | for python in PY_VERSIONS 72 | for django in DJ_VERSIONS 73 | if not should_skip(python, django) 74 | ], 75 | ) 76 | def tests(session, django): 77 | session.run_install( 78 | "uv", 79 | "sync", 80 | "--frozen", 81 | "--inexact", 82 | "--no-install-package", 83 | "django", 84 | "--python", 85 | session.python, 86 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 87 | ) 88 | 89 | if django == DJMAIN: 90 | session.install( 91 | "django @ https://github.com/django/django/archive/refs/heads/main.zip" 92 | ) 93 | else: 94 | session.install(f"django=={django}") 95 | 96 | command = ["python", "-m", "pytest"] 97 | if session.posargs: 98 | args = [] 99 | for arg in session.posargs: 100 | if arg: 101 | args.extend(arg.split(" ")) 102 | command.extend(args) 103 | session.run(*command) 104 | 105 | 106 | @nox.session 107 | def coverage(session): 108 | session.run_install( 109 | "uv", 110 | "sync", 111 | "--frozen", 112 | "--python", 113 | PY_DEFAULT, 114 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 115 | ) 116 | 117 | try: 118 | command = ["python", "-m", "pytest", "--cov", "--cov-report="] 119 | if session.posargs: 120 | args = [] 121 | for arg in session.posargs: 122 | if arg: 123 | args.extend(arg.split(" ")) 124 | command.extend(args) 125 | session.run(*command) 126 | finally: 127 | # 0 -> OK 128 | # 2 -> code coverage percent unmet 129 | success_codes = [0, 2] 130 | 131 | report_cmd = ["python", "-m", "coverage", "report"] 132 | session.run(*report_cmd, success_codes=success_codes) 133 | 134 | if summary := os.getenv("GITHUB_STEP_SUMMARY"): 135 | report_cmd.extend(["--skip-covered", "--skip-empty", "--format=markdown"]) 136 | 137 | with Path(summary).open("a") as output_buffer: 138 | output_buffer.write("") 139 | output_buffer.write("### Coverage\n\n") 140 | output_buffer.flush() 141 | session.run( 142 | *report_cmd, stdout=output_buffer, success_codes=success_codes 143 | ) 144 | else: 145 | session.run( 146 | "python", 147 | "-m", 148 | "coverage", 149 | "html", 150 | "--skip-covered", 151 | "--skip-empty", 152 | success_codes=success_codes, 153 | ) 154 | 155 | 156 | @nox.session 157 | def mypy(session): 158 | session.run_install( 159 | "uv", 160 | "sync", 161 | "--group", 162 | "types", 163 | "--frozen", 164 | "--python", 165 | PY_LATEST, 166 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 167 | ) 168 | 169 | command = ["python", "-m", "mypy", "."] 170 | if session.posargs: 171 | args = [] 172 | for arg in session.posargs: 173 | if arg: 174 | args.extend(arg.split(" ")) 175 | command.extend(args) 176 | session.run(*command) 177 | 178 | 179 | @nox.session 180 | def pyright(session): 181 | session.run_install( 182 | "uv", 183 | "sync", 184 | "--group", 185 | "types", 186 | "--frozen", 187 | "--python", 188 | PY_LATEST, 189 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 190 | ) 191 | 192 | command = ["basedpyright"] 193 | if session.posargs: 194 | args = [] 195 | for arg in session.posargs: 196 | if arg: 197 | args.extend(arg.split(" ")) 198 | command.extend(args) 199 | session.run(*command) 200 | 201 | 202 | @nox.session 203 | def lint(session): 204 | session.run( 205 | "uv", 206 | "run", 207 | "--with", 208 | "pre-commit-uv", 209 | "--python", 210 | PY_LATEST, 211 | "pre-commit", 212 | "run", 213 | "--all-files", 214 | ) 215 | 216 | 217 | @nox.session 218 | def gha_matrix(session): 219 | sessions = session.run("nox", "-l", "--json", silent=True) 220 | matrix = { 221 | "include": [ 222 | { 223 | "python-version": session["python"], 224 | "django-version": session["call_spec"]["django"], 225 | } 226 | for session in json.loads(sessions) 227 | if session["name"] == "tests" 228 | ] 229 | } 230 | with Path(os.environ["GITHUB_OUTPUT"]).open("a") as fh: 231 | print(f"matrix={matrix}", file=fh) 232 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests. 4 | 5 | You should first check if there is a [GitHub issue](https://github.com/joshuadavidthomas/django-bird/issues) already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution. 6 | 7 | Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first. 8 | 9 | We adhere to Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the [Code of Conduct](https://www.djangoproject.com/conduct/) before contributing. 10 | 11 | ## Requirements 12 | 13 | - [uv](https://github.com/astral-sh/uv) - Modern Python toolchain that handles: 14 | - Python version management and installation 15 | - Virtual environment creation and management 16 | - Fast, reliable dependency resolution and installation 17 | - Reproducible builds via lockfile 18 | - [direnv](https://github.com/direnv/direnv) (Optional) - Automatic environment variable loading 19 | - [just](https://github.com/casey/just) (Optional) - Command runner for development tasks 20 | 21 | ### `Justfile` 22 | 23 | The repository includes a `Justfile` that provides all common development tasks with a consistent interface. Running `just` without arguments shows all available commands and their descriptions. 24 | 25 | 47 | ```bash 48 | $ just 49 | $ # just --list --list-submodules 50 | 51 | Available recipes: 52 | bootstrap 53 | coverage *ARGS 54 | lint 55 | lock *ARGS 56 | manage *COMMAND 57 | test *ARGS 58 | testall *ARGS 59 | types *ARGS 60 | copier: 61 | copy TEMPLATE_PATH DESTINATION_PATH="." # Create a copier answers file 62 | recopy ANSWERS_FILE *ARGS # Recopy the project from the original template 63 | recopy-all *ARGS # Loop through all answers files and recopy the project using copier 64 | update ANSWERS_FILE *ARGS # Update the project using a copier answers file 65 | update-all *ARGS # Loop through all answers files and update the project using copier 66 | docs: 67 | build LOCATION="docs/_build/html" # Build documentation using Sphinx 68 | serve PORT="8000" # Serve documentation locally 69 | project: 70 | bump *ARGS 71 | release *ARGS 72 | ``` 73 | 74 | 75 | All commands below will contain the full command as well as its `just` counterpart. 76 | 77 | ## Setup 78 | 79 | The following instructions will use `uv` and assume a Unix-like operating system (Linux or macOS). 80 | 81 | Windows users will need to adjust commands accordingly, though the core workflow remains the same. 82 | 83 | Alternatively, any Python package manager that supports installing from `pyproject.toml` ([PEP 621](https://peps.python.org/pep-0621/)) can be used. If not using `uv`, ensure you have Python installed from [python.org](https://www.python.org/) or another source such as [`pyenv`](https://github.com/pyenv/pyenv). 84 | 85 | 1. Fork the repository and clone it locally. 86 | 87 | 2. Use `uv` to bootstrap your development environment. 88 | 89 | ```bash 90 | uv python install 91 | uv sync --locked 92 | # just bootstrap 93 | ``` 94 | 95 | This will install the correct Python version, create and configure a virtual environment, and install all dependencies. 96 | 97 | ## Tests 98 | 99 | The project uses [`pytest`](https://docs.pytest.org/) for testing and [`nox`](https://nox.thea.codes/) to run the tests in multiple environments. 100 | 101 | To run the test suite against the default versions of Python (lower bound of supported versions) and Django (lower bound of LTS versions): 102 | 103 | ```bash 104 | uv run nox --session test 105 | # just test 106 | ``` 107 | 108 | To run the test suite against the entire matrix of supported versions of Python and Django: 109 | 110 | ```bash 111 | uv run nox --session tests 112 | # just testall 113 | ``` 114 | 115 | Both can be passed additional arguments that will be provided to `pytest`. 116 | 117 | ```bash 118 | uv run nox --session test -- -v --last-failed 119 | uv run nox --session tests -- --failed-first --maxfail=1 120 | # just test -v --last-failed 121 | # just testall --failed-first --maxfail=1 122 | ``` 123 | 124 | ### Coverage 125 | 126 | The project uses [`coverage.py`](https://github.com/nedbat/coverage.py) to measure code coverage and aims to maintain 100% coverage across the codebase. 127 | 128 | To run the test suite and measure code coverage: 129 | 130 | ```bash 131 | uv run nox --session coverage 132 | # just coverage 133 | ``` 134 | 135 | All pull requests must include tests to maintain 100% coverage. Coverage configuration can be found in the `[tools.coverage.*]` sections of [`pyproject.toml`](pyproject.toml). 136 | 137 | ## Linting and Formatting 138 | 139 | This project enforces code quality standards using [`pre-commit`](https://github.com/pre-commit/pre-commit). 140 | 141 | To run all formatters and linters: 142 | 143 | ```bash 144 | uv run nox --session lint 145 | # just lint 146 | ``` 147 | 148 | The following checks are run: 149 | 150 | - [ruff](https://github.com/astral-sh/ruff) - Fast Python linter and formatter 151 | - Code formatting for Python files in documentation ([blacken-docs](https://github.com/adamchainz/blacken-docs)) 152 | - Django compatibility checks ([django-upgrade](https://github.com/adamchainz/django-upgrade)) 153 | - TOML and YAML validation 154 | - Basic file hygiene (trailing whitespace, file endings) 155 | 156 | To enable pre-commit hooks after cloning: 157 | 158 | ```bash 159 | uv run --with pre-commit pre-commit install 160 | ``` 161 | 162 | Configuration for these tools can be found in: 163 | 164 | - [`.pre-commit-config.yaml`](.pre-commit-config.yaml) - Pre-commit hook configuration 165 | - [`pyproject.toml`](pyproject.toml) - Ruff and other tool settings 166 | 167 | ## Continuous Integration 168 | 169 | This project uses GitHub Actions for CI/CD. The workflows can be found in [`.github/workflows/`](.github/workflows/). 170 | 171 | - [`test.yml`](.github/workflows/test.yml) - Runs on pushes to the `main` branch and on all PRs 172 | - Tests across Python/Django version matrix 173 | - Static type checking 174 | - Coverage reporting 175 | - [`release.yml`](.github/workflows/release.yml) - Runs on GitHub release creation 176 | - Runs the [`test.yml`](.github/workflows/test.yml) workflow 177 | - Builds package 178 | - Publishes to PyPI 179 | 180 | PRs must pass all CI checks before being merged. 181 | -------------------------------------------------------------------------------- /docs/slots.md: -------------------------------------------------------------------------------- 1 | # Slots 2 | 3 | Slots allow you to define areas in your components where content can be inserted when the component is used. This feature provides flexibility and reusability to your components. 4 | 5 | ## Default Slot 6 | 7 | Every component in django-bird has a default slot. 8 | 9 | There are three ways to reference this default slot in your component template: 10 | 11 | 1. `{{ slot }}` 12 | 2. `{% bird:slot %}{% endbird:slot %}` 13 | 3. `{% bird:slot default %}{% endbird:slot %}` 14 | 15 | These are all equivalent and will render the content placed between the opening and closing tags of your component when it's used. Let's look at each approach: 16 | 17 | Using `{{ slot }}`: 18 | 19 | ```{code-block} htmldjango 20 | :caption: templates/bird/button.html 21 | 22 | 25 | ``` 26 | 27 | Alternatively, you can use `{% bird:slot %}{% endbird:slot %}`: 28 | 29 | ```{code-block} htmldjango 30 | :caption: templates/bird/button.html 31 | 32 | 36 | ``` 37 | 38 | Or, you can explicitly name the default slot with `{% bird:slot default %}{% endbird:slot %}`: 39 | 40 | ```{code-block} htmldjango 41 | :caption: templates/bird/button.html 42 | 43 | 47 | ``` 48 | 49 | When using any of these component templates, you would write: 50 | 51 | ```htmldjango 52 | {% bird button %} 53 | Click me 54 | {% endbird %} 55 | ``` 56 | 57 | And the output would be: 58 | 59 | ```html 60 | 63 | ``` 64 | 65 | All three versions of the component template will produce the same output. Choose the syntax that you find most readable or that best fits your specific use case. 66 | 67 | ## Named Slots 68 | 69 | In addition to the default slot, you can define named slots for more complex component structures. Named slots allow you to create more flexible and reusable components by specifying multiple areas where content can be inserted. 70 | 71 | ### Basic Usage 72 | 73 | Here's a basic example of a component with a named slot: 74 | 75 | ```{code-block} htmldjango 76 | :caption: templates/bird/button.html 77 | 78 | 83 | ``` 84 | 85 | To use this component with a named slot: 86 | 87 | ```htmldjango 88 | {% bird button %} 89 | {% bird:slot leading-icon %} 90 | 91 | {% endbird:slot %} 92 | Click me {# This content goes into the default slot #} 93 | {% endbird %} 94 | ``` 95 | 96 | This would output: 97 | 98 | ```html 99 | 103 | ``` 104 | 105 | ### Checking for Slot Content 106 | 107 | django-bird provides a `slots` variable in the template context that allows you to check if a certain slot has been passed in. This can be useful for conditional rendering or applying different styles based on whether a slot is filled. 108 | 109 | Here's an example of how you might use this: 110 | 111 | ```{code-block} htmldjango 112 | :caption: templates/bird/button.html 113 | 114 | 115 | {% if slots.leading-icon %} 116 | 117 | {% bird:slot leading-icon %} 118 | {% endbird:slot %} 119 | 120 | {% endif %} 121 | 122 | {{ slot }} 123 | 124 | ``` 125 | 126 | Now, you can use this component in different ways: 127 | 128 | ```htmldjango 129 | {% bird button %} 130 | Click me 131 | {% endbird %} 132 | ``` 133 | 134 | This would output: 135 | 136 | ```html 137 | 140 | ``` 141 | 142 | But if you include the `leading-icon` slot: 143 | 144 | ```htmldjango 145 | {% bird button %} 146 | {% bird:slot leading-icon %} 147 | 148 | {% endbird:slot %} 149 | 150 | Click me 151 | {% endbird %} 152 | ``` 153 | 154 | It would output: 155 | 156 | ```html 157 | 164 | ``` 165 | 166 | ### Multiple Named Slots 167 | 168 | You can define as many named slots as you need in a component. Here's an example with multiple named slots, demonstrating different ways to reference slot content: 169 | 170 | ```{code-block} htmldjango 171 | :caption: templates/bird/card.html 172 | 173 |
174 | {% if slots.header %} 175 |
176 | {% bird:slot header %} 177 | {% endbird:slot %} 178 |
179 | {% endif %} 180 | 181 |
182 | {{ slot }} 183 |
184 | 185 | {% if slots.footer %} 186 | 189 | {% endif %} 190 |
191 | ``` 192 | 193 | Note the different approaches used here: 194 | 195 | 1. For the `header`, we use the `{% bird:slot header %}{% endbird:slot %}` syntax. 196 | 2. For the main content, we use the `{{ slot }}` syntax for the default slot. 197 | 3. For the `footer`, we directly reference `{{ slots.footer }}`. 198 | 199 | All these approaches are valid and will render the slot content. The choice between them often comes down to personal preference or specific use cases (e.g., needing to wrap the slot content in additional HTML). 200 | 201 | This allows for very flexible usage of the component: 202 | 203 | ```htmldjango 204 | {% bird card %} 205 | {% bird:slot header %} 206 | Card Title 207 | {% endbird:slot %} 208 | 209 | This is the main content of the card. 210 | 211 | {% bird:slot footer %} 212 | Card Footer 213 | {% endbird:slot %} 214 | {% endbird %} 215 | ``` 216 | 217 | The output would be: 218 | 219 | ```html 220 |
221 |
222 | Card Title 223 |
224 | 225 |
226 | This is the main content of the card. 227 |
228 | 229 | 232 |
233 | ``` 234 | 235 | By using named slots and the `slots` dictionary, you can create highly adaptable components that can be used in a variety of contexts while maintaining a consistent structure. The ability to check for the existence of slot content (`{% if slots.header %}`) and to reference it directly (`{{ slots.footer }}`) provides great flexibility in how you structure your components. 236 | 237 | ## Fallback Content 238 | 239 | You can provide default content for both named slots and the default slot that will fallback if nothing is passed in: 240 | 241 | ```{code-block} htmldjango 242 | :caption: templates/bird/button.html 243 | 244 | 255 | ``` 256 | 257 | If you use this component without providing content: 258 | 259 | ```htmldjango 260 | {% bird button %} 261 | {% endbird %} 262 | ``` 263 | 264 | It will output: 265 | 266 | ```html 267 | 274 | ``` 275 | 276 | Remember, the default slot is always available, even when you define named slots. This allows for flexible component design that can adapt to different usage scenarios. 277 | -------------------------------------------------------------------------------- /docs/development/just.md: -------------------------------------------------------------------------------- 1 | # Justfile 2 | 3 | This project uses [Just](https://github.com/casey/just) as a command runner. 4 | 5 | The following commands are available: 6 | 7 | 19 | - [bootstrap](#bootstrap) 20 | - [coverage](#coverage) 21 | - [lint](#lint) 22 | - [lock](#lock) 23 | - [manage](#manage) 24 | - [test](#test) 25 | - [testall](#testall) 26 | - [types](#types) 27 | - [copier::copy](#copier::copy) 28 | - [copier::recopy](#copier::recopy) 29 | - [copier::recopy-all](#copier::recopy-all) 30 | - [copier::update](#copier::update) 31 | - [copier::update-all](#copier::update-all) 32 | - [docs::build](#docs::build) 33 | - [docs::serve](#docs::serve) 34 | - [project::bump](#project::bump) 35 | - [project::release](#project::release) 36 | 37 | 38 | ## Commands 39 | 40 | ```{code-block} shell 41 | :class: copy 42 | 43 | $ just --list 44 | ``` 45 | 54 | ``` 55 | Available recipes: 56 | bootstrap 57 | coverage *ARGS 58 | lint 59 | lock *ARGS 60 | manage *COMMAND 61 | test *ARGS 62 | testall *ARGS 63 | types *ARGS 64 | copier ... 65 | docs ... 66 | project ... 67 | 68 | ``` 69 | 70 | 71 | 93 | ### bootstrap 94 | 95 | ```{code-block} shell 96 | :class: copy 97 | 98 | $ just bootstrap 99 | ``` 100 | 101 | ```{code-block} shell 102 | bootstrap: 103 | uv python install 104 | uv sync --locked 105 | ``` 106 | 107 | ### coverage 108 | 109 | ```{code-block} shell 110 | :class: copy 111 | 112 | $ just coverage 113 | ``` 114 | 115 | ```{code-block} shell 116 | coverage *ARGS: 117 | @just nox coverage {{ ARGS }} 118 | ``` 119 | 120 | ### lint 121 | 122 | ```{code-block} shell 123 | :class: copy 124 | 125 | $ just lint 126 | ``` 127 | 128 | ```{code-block} shell 129 | lint: 130 | @just nox lint 131 | ``` 132 | 133 | ### lock 134 | 135 | ```{code-block} shell 136 | :class: copy 137 | 138 | $ just lock 139 | ``` 140 | 141 | ```{code-block} shell 142 | lock *ARGS: 143 | uv lock {{ ARGS }} 144 | ``` 145 | 146 | ### manage 147 | 148 | ```{code-block} shell 149 | :class: copy 150 | 151 | $ just manage 152 | ``` 153 | 154 | ```{code-block} shell 155 | manage *COMMAND: 156 | #!/usr/bin/env python 157 | import sys 158 | 159 | try: 160 | from django.conf import settings 161 | from django.core.management import execute_from_command_line 162 | except ImportError as exc: 163 | raise ImportError( 164 | "Couldn't import Django. Are you sure it's installed and " 165 | "available on your PYTHONPATH environment variable? Did you " 166 | "forget to activate a virtual environment?" 167 | ) from exc 168 | 169 | settings.configure(INSTALLED_APPS=["django_bird"]) 170 | execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 171 | ``` 172 | 173 | ### test 174 | 175 | ```{code-block} shell 176 | :class: copy 177 | 178 | $ just test 179 | ``` 180 | 181 | ```{code-block} shell 182 | test *ARGS: 183 | @just nox test {{ ARGS }} 184 | ``` 185 | 186 | ### testall 187 | 188 | ```{code-block} shell 189 | :class: copy 190 | 191 | $ just testall 192 | ``` 193 | 194 | ```{code-block} shell 195 | testall *ARGS: 196 | @just nox tests {{ ARGS }} 197 | ``` 198 | 199 | ### types 200 | 201 | ```{code-block} shell 202 | :class: copy 203 | 204 | $ just types 205 | ``` 206 | 207 | ```{code-block} shell 208 | types *ARGS: 209 | @just nox types {{ ARGS }} 210 | ``` 211 | 212 | ### copier::copy 213 | 214 | ```{code-block} shell 215 | :class: copy 216 | 217 | $ just copier::copy 218 | ``` 219 | 220 | ```{code-block} shell 221 | # Create a copier answers file 222 | [no-cd] 223 | copy TEMPLATE_PATH DESTINATION_PATH=".": 224 | uv run copier copy --trust {{ TEMPLATE_PATH }} {{ DESTINATION_PATH }} 225 | ``` 226 | 227 | ### copier::recopy 228 | 229 | ```{code-block} shell 230 | :class: copy 231 | 232 | $ just copier::recopy 233 | ``` 234 | 235 | ```{code-block} shell 236 | # Recopy the project from the original template 237 | [no-cd] 238 | recopy ANSWERS_FILE *ARGS: 239 | uv run copier recopy --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 240 | ``` 241 | 242 | ### copier::recopy-all 243 | 244 | ```{code-block} shell 245 | :class: copy 246 | 247 | $ just copier::recopy-all 248 | ``` 249 | 250 | ```{code-block} shell 251 | # Loop through all answers files and recopy the project using copier 252 | [no-cd] 253 | @recopy-all *ARGS: 254 | for file in `ls .copier/`; do just copier recopy .copier/$file "{{ ARGS }}"; done 255 | ``` 256 | 257 | ### copier::update 258 | 259 | ```{code-block} shell 260 | :class: copy 261 | 262 | $ just copier::update 263 | ``` 264 | 265 | ```{code-block} shell 266 | # Update the project using a copier answers file 267 | [no-cd] 268 | update ANSWERS_FILE *ARGS: 269 | uv run copier update --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 270 | ``` 271 | 272 | ### copier::update-all 273 | 274 | ```{code-block} shell 275 | :class: copy 276 | 277 | $ just copier::update-all 278 | ``` 279 | 280 | ```{code-block} shell 281 | # Loop through all answers files and update the project using copier 282 | [no-cd] 283 | @update-all *ARGS: 284 | for file in `ls .copier/`; do just copier update .copier/$file "{{ ARGS }}"; done 285 | ``` 286 | 287 | ### docs::build 288 | 289 | ```{code-block} shell 290 | :class: copy 291 | 292 | $ just docs::build 293 | ``` 294 | 295 | ```{code-block} shell 296 | # Build documentation using Sphinx 297 | [no-cd] 298 | build LOCATION="docs/_build/html": cog 299 | uv run --group docs sphinx-build docs {{ LOCATION }} 300 | ``` 301 | 302 | ### docs::serve 303 | 304 | ```{code-block} shell 305 | :class: copy 306 | 307 | $ just docs::serve 308 | ``` 309 | 310 | ```{code-block} shell 311 | # Serve documentation locally 312 | [no-cd] 313 | serve PORT="8000": cog 314 | #!/usr/bin/env sh 315 | HOST="localhost" 316 | if [ -f "/.dockerenv" ]; then 317 | HOST="0.0.0.0" 318 | fi 319 | uv run --group docs sphinx-autobuild docs docs/_build/html --host "$HOST" --port {{ PORT }} 320 | ``` 321 | 322 | ### project::bump 323 | 324 | ```{code-block} shell 325 | :class: copy 326 | 327 | $ just project::bump 328 | ``` 329 | 330 | ```{code-block} shell 331 | [no-cd] 332 | @bump *ARGS: 333 | {{ justfile_directory() }}/.bin/bump.py version {{ ARGS }} 334 | ``` 335 | 336 | ### project::release 337 | 338 | ```{code-block} shell 339 | :class: copy 340 | 341 | $ just project::release 342 | ``` 343 | 344 | ```{code-block} shell 345 | [no-cd] 346 | @release *ARGS: 347 | {{ justfile_directory() }}/.bin/bump.py release {{ ARGS }} 348 | ``` 349 | 350 | 351 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [dependency-groups] 6 | dev = [ 7 | "copier>=9.3.1", 8 | "copier-templates-extensions>=0.3.0", 9 | "coverage[toml]>=7.6.4", 10 | "faker>=30.3.0", 11 | "django-stubs>=5.0.4", 12 | "django-stubs-ext>=5.0.4", 13 | "model-bakery>=1.19.5", 14 | "nox[uv]>=2024.4.15", 15 | "pytest>=8.3.3", 16 | "pytest-cov>=5.0.0", 17 | "pytest-django>=4.9.0", 18 | "pytest-randomly>=3.15.0", 19 | "pytest-xdist>=3.6.1", 20 | "ruff>=0.6.6" 21 | ] 22 | docs = [ 23 | "furo>=2024.8.6", 24 | "myst-parser>=4.0.0", 25 | "sphinx>=8.0.2", 26 | "sphinx-autobuild>=2024.10.3", 27 | "sphinx-autodoc2>=0.5.0", 28 | "sphinx-copybutton>=0.5.2", 29 | "sphinx-inline-tabs>=2023.4.21" 30 | ] 31 | types = [ 32 | "basedpyright>=1.27.1", 33 | "django-stubs>=5.1.0", 34 | "django-stubs-ext>=5.1.0", 35 | "mypy>=1.11.2" 36 | ] 37 | 38 | [project] 39 | authors = [{name = "Josh Thomas", email = "josh@joshthomas.dev"}] 40 | classifiers = [ 41 | "Development Status :: 4 - Beta", 42 | "Framework :: Django", 43 | "Framework :: Django :: 4.2", 44 | "Framework :: Django :: 5.0", 45 | "Framework :: Django :: 5.1", 46 | "Framework :: Django :: 5.2", 47 | "License :: OSI Approved :: MIT License", 48 | "Operating System :: OS Independent", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3 :: Only", 52 | "Programming Language :: Python :: 3.10", 53 | "Programming Language :: Python :: 3.11", 54 | "Programming Language :: Python :: 3.12", 55 | "Programming Language :: Python :: 3.13", 56 | "Programming Language :: Python :: Implementation :: CPython" 57 | ] 58 | dependencies = ["django>=4.2", "pluggy>=1.5.0"] 59 | description = "High-flying components for perfectionists with deadlines" 60 | dynamic = ["version"] 61 | keywords = [] 62 | license = {file = "LICENSE"} 63 | name = "django-bird" 64 | readme = "README.md" 65 | requires-python = ">=3.10" 66 | 67 | [project.urls] 68 | Documentation = "https://django-bird.readthedocs.io/" 69 | Issues = "https://github.com/joshuadavidthomas/django-bird/issues" 70 | Source = "https://github.com/joshuadavidthomas/django-bird" 71 | 72 | [tool.basedpyright] 73 | exclude = ["**/__pycache__"] 74 | include = ["src"] 75 | reportAny = false 76 | reportExplicitAny = false 77 | reportUnusedCallResult = false 78 | 79 | [[tool.basedpyright.executionEnvironments]] 80 | reportUnreachable = false 81 | root = "src/django_bird/_typing.py" 82 | 83 | [[tool.basedpyright.executionEnvironments]] 84 | reportReturnType = false 85 | root = "src/django_bird/plugins/hookspecs.py" 86 | 87 | [[tool.basedpyright.executionEnvironments]] 88 | # TODO: fix import cycles by rearchitecting modules, at some point 89 | reportImportCycles = false 90 | root = "src" 91 | 92 | [[tool.basedpyright.executionEnvironments]] 93 | reportArgumentType = false 94 | reportMissingParameterType = false 95 | reportPrivateUsage = false 96 | reportUnknownArgumentType = false 97 | reportUnknownMemberType = false 98 | reportUnknownParameterType = false 99 | reportUnknownVariableType = false 100 | reportUnusedCallResult = false 101 | root = "tests" 102 | 103 | [tool.bumpver] 104 | commit = true 105 | commit_message = ":bookmark: bump version {old_version} -> {new_version}" 106 | current_version = "0.17.3" 107 | push = false # set to false for CI 108 | tag = false 109 | version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" 110 | 111 | [tool.bumpver.file_patterns] 112 | ".copier/package.yml" = ['current_version: {version}'] 113 | "src/django_bird/__init__.py" = ['__version__ = "{version}"'] 114 | "tests/test_version.py" = ['assert __version__ == "{version}"'] 115 | 116 | [tool.coverage.paths] 117 | source = ["src"] 118 | 119 | [tool.coverage.report] 120 | exclude_lines = [ 121 | "pragma: no cover", 122 | "if DEBUG:", 123 | "if not DEBUG:", 124 | "if settings.DEBUG:", 125 | "if TYPE_CHECKING:", 126 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:' 127 | ] 128 | fail_under = 98 129 | 130 | [tool.coverage.run] 131 | omit = [ 132 | "src/django_bird/migrations/*", 133 | "src/django_bird/_typing.py", 134 | "src/django_bird/views.py", # TODO: remove when not empty 135 | "tests/*" 136 | ] 137 | source = ["src/django_bird"] 138 | 139 | [tool.django-stubs] 140 | django_settings_module = "tests.settings" 141 | strict_settings = false 142 | 143 | [tool.djlint] 144 | blank_line_after_tag = "endblock,endpartialdef,extends,load" 145 | blank_line_before_tag = "block,partialdef" 146 | custom_blocks = "bird,bird:slot,partialdef" 147 | ignore = "H031" # Don't require `meta` tag keywords 148 | indent = 2 149 | profile = "django" 150 | 151 | [tool.hatch.build] 152 | exclude = [".*", "Justfile"] 153 | 154 | [tool.hatch.build.targets.wheel] 155 | packages = ["src/django_bird"] 156 | 157 | [tool.hatch.version] 158 | path = "src/django_bird/__init__.py" 159 | 160 | [tool.mypy] 161 | check_untyped_defs = true 162 | exclude = ["docs", "tests", "migrations", "venv", ".venv"] 163 | mypy_path = "src/" 164 | no_implicit_optional = true 165 | plugins = ["mypy_django_plugin.main"] 166 | warn_redundant_casts = true 167 | warn_unused_configs = true 168 | warn_unused_ignores = true 169 | 170 | [[tool.mypy.overrides]] 171 | ignore_errors = true 172 | ignore_missing_imports = true 173 | module = ["*.migrations.*", "docs.*", "tests.*"] 174 | 175 | [[tool.mypy.overrides]] 176 | disable_error_code = "empty-body" 177 | module = ["django_bird.plugins.hookspecs"] 178 | 179 | [tool.mypy_django_plugin] 180 | ignore_missing_model_attributes = true 181 | 182 | [tool.pytest.ini_options] 183 | addopts = "--create-db -n auto --dist loadfile --doctest-modules" 184 | django_find_project = false 185 | markers = ["default_app_settings", "slow"] 186 | norecursedirs = ".* bin build dist *.egg htmlcov logs node_modules templates venv" 187 | python_files = "tests.py test_*.py *_tests.py" 188 | pythonpath = "src" 189 | testpaths = ["tests"] 190 | 191 | [tool.ruff] 192 | # Exclude a variety of commonly ignored directories. 193 | exclude = [ 194 | ".bzr", 195 | ".direnv", 196 | ".eggs", 197 | ".git", 198 | ".github", 199 | ".hg", 200 | ".mypy_cache", 201 | ".ruff_cache", 202 | ".svn", 203 | ".tox", 204 | ".venv", 205 | "__pypackages__", 206 | "_build", 207 | "build", 208 | "dist", 209 | "migrations", 210 | "node_modules", 211 | "venv" 212 | ] 213 | extend-include = ["*.pyi?"] 214 | indent-width = 4 215 | # Same as Black. 216 | line-length = 88 217 | # Assume Python >3.10 218 | target-version = "py310" 219 | 220 | [tool.ruff.format] 221 | # Like Black, indent with spaces, rather than tabs. 222 | indent-style = "space" 223 | # Like Black, automatically detect the appropriate line ending. 224 | line-ending = "auto" 225 | # Like Black, use double quotes for strings. 226 | quote-style = "double" 227 | 228 | [tool.ruff.lint] 229 | # Allow unused variables when underscore-prefixed. 230 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 231 | # Allow autofix for all enabled rules (when `--fix`) is provided. 232 | fixable = ["A", "B", "C", "D", "E", "F", "I"] 233 | ignore = ["E501", "E741"] # temporary 234 | select = [ 235 | "B", # flake8-bugbear 236 | "E", # Pycodestyle 237 | "F", # Pyflakes 238 | "I", # isort 239 | "UP" # pyupgrade 240 | ] 241 | unfixable = [] 242 | 243 | [tool.ruff.lint.isort] 244 | force-single-line = true 245 | known-first-party = ["django_bird"] 246 | required-imports = ["from __future__ import annotations"] 247 | 248 | [tool.ruff.lint.per-file-ignores] 249 | # Tests can use magic values, assertions, and relative imports 250 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 251 | 252 | [tool.ruff.lint.pyupgrade] 253 | # Preserve types, even if a file imports `from __future__ import annotations`. 254 | keep-runtime-typing = true 255 | 256 | [tool.uv] 257 | required-version = ">=0.7" 258 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import logging 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import pytest 10 | from django.conf import settings 11 | from django.template.backends.django import DjangoTemplates 12 | from django.template.backends.django import Template as DjangoTemplate 13 | from django.template.engine import Engine 14 | from django.test import override_settings 15 | from django.urls import clear_url_caches 16 | from django.urls import include 17 | from django.urls import path 18 | 19 | from .settings import DEFAULT_SETTINGS 20 | 21 | if TYPE_CHECKING: 22 | from django_bird.components import Component 23 | 24 | SLOW_MARK = "slow" 25 | SLOW_CLI_ARG = "--slow" 26 | 27 | pytest_plugins = [] 28 | 29 | 30 | def pytest_addoption(parser: pytest.Parser) -> None: 31 | parser.addoption(SLOW_CLI_ARG, action="store_true", help="run tests marked as slow") 32 | 33 | 34 | def pytest_configure(config: pytest.Config) -> None: # pyright: ignore [reportUnusedParameter] 35 | logging.disable(logging.CRITICAL) 36 | 37 | settings.configure(**DEFAULT_SETTINGS, **TEST_SETTINGS) 38 | 39 | 40 | def pytest_runtest_setup(item: pytest.Item) -> None: 41 | if SLOW_MARK in item.keywords and not item.config.getoption(SLOW_CLI_ARG): 42 | pytest.skip(f"pass {SLOW_CLI_ARG} to run slow tests") 43 | 44 | 45 | TEST_SETTINGS = { 46 | "INSTALLED_APPS": [ 47 | "django_bird", 48 | "django.contrib.staticfiles", 49 | ], 50 | "STATIC_URL": "/static/", 51 | "STATICFILES_FINDERS": [ 52 | "django.contrib.staticfiles.finders.FileSystemFinder", 53 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 54 | "django_bird.staticfiles.BirdAssetFinder", 55 | ], 56 | "TEMPLATES": [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [ 60 | Path(__file__).parent / "templates", 61 | ], 62 | "OPTIONS": { 63 | "builtins": [ 64 | "django.template.defaulttags", 65 | "django_bird.templatetags.django_bird", 66 | ], 67 | "loaders": [ 68 | "django.template.loaders.filesystem.Loader", 69 | "django.template.loaders.app_directories.Loader", 70 | ], 71 | }, 72 | } 73 | ], 74 | } 75 | 76 | 77 | @pytest.fixture(autouse=True) 78 | def setup_urls(): 79 | urlpatterns = [ 80 | path("__bird__/", include("django_bird.urls")), 81 | ] 82 | 83 | clear_url_caches() 84 | 85 | with override_settings( 86 | ROOT_URLCONF=type( 87 | "urls", 88 | (), 89 | {"urlpatterns": urlpatterns}, 90 | ), 91 | ): 92 | yield 93 | 94 | clear_url_caches() 95 | 96 | 97 | @pytest.fixture 98 | def templates_dir(tmp_path): 99 | templates_dir = tmp_path / "templates" 100 | templates_dir.mkdir() 101 | return templates_dir 102 | 103 | 104 | @pytest.fixture(autouse=True) 105 | def override_templates_settings(templates_dir): 106 | with override_settings( 107 | TEMPLATES=[ 108 | settings.TEMPLATES[0] 109 | | { 110 | "DIRS": [ 111 | *settings.TEMPLATES[0]["DIRS"], 112 | templates_dir, 113 | ] 114 | } 115 | ] 116 | ): 117 | yield 118 | 119 | 120 | @pytest.fixture 121 | def override_app_settings(): 122 | from django_bird.conf import DJANGO_BIRD_SETTINGS_NAME 123 | 124 | @contextlib.contextmanager 125 | def _override_app_settings(**kwargs): 126 | with override_settings(**{DJANGO_BIRD_SETTINGS_NAME: {**kwargs}}): 127 | yield 128 | 129 | return _override_app_settings 130 | 131 | 132 | @pytest.fixture(autouse=True) 133 | def data_bird_attr_app_setting(override_app_settings, request): 134 | enable = "default_app_settings" in request.keywords 135 | 136 | with override_app_settings(ENABLE_BIRD_ATTRS=enable): 137 | yield 138 | 139 | 140 | @pytest.fixture 141 | def create_template(): 142 | def _create_template(template_file: Path) -> DjangoTemplate: 143 | engine = Engine( 144 | builtins=["django_bird.templatetags.django_bird"], 145 | dirs=[str(template_file.parent)], 146 | ) 147 | template = engine.get_template(template_file.name) 148 | backend = DjangoTemplates( 149 | { 150 | "NAME": "django", 151 | "DIRS": [], 152 | "APP_DIRS": True, 153 | "OPTIONS": { 154 | "autoescape": True, 155 | "debug": False, 156 | "context_processors": [], 157 | }, 158 | } 159 | ) 160 | return DjangoTemplate(template, backend) 161 | 162 | return _create_template 163 | 164 | 165 | @dataclass 166 | class ExampleTemplate: 167 | base: Path 168 | include: Path 169 | template: Path 170 | used_components: list[Component] 171 | unused_components: list[Component] 172 | 173 | @property 174 | def content(self): 175 | return self.template.read_text() 176 | 177 | 178 | @pytest.fixture 179 | def example_template(templates_dir): 180 | from django_bird.components import Component 181 | from django_bird.staticfiles import CSS 182 | from django_bird.staticfiles import JS 183 | 184 | from .utils import TestAsset 185 | from .utils import TestComponent 186 | 187 | button = TestComponent(name="button", content="").create( 188 | templates_dir 189 | ) 190 | TestAsset( 191 | component=button, 192 | content=".button { color: blue; }", 193 | asset_type=CSS, 194 | ).create() 195 | TestAsset( 196 | component=button, content="console.log('button');", asset_type=JS 197 | ).create() 198 | 199 | alert = TestComponent( 200 | name="alert", content='
{{ slot }}
' 201 | ).create(templates_dir) 202 | TestAsset( 203 | component=alert, content=".alert { color: red; }", asset_type=CSS 204 | ).create() 205 | 206 | banner = TestComponent( 207 | name="banner", content="
{{ slot }}
" 208 | ).create(templates_dir) 209 | 210 | toast = TestComponent(name="toast", content="
{{ slot }}
").create( 211 | templates_dir 212 | ) 213 | TestAsset( 214 | component=toast, 215 | content=".toast { color: pink; }", 216 | asset_type=CSS, 217 | ).create() 218 | TestAsset(component=toast, content="console.log('toast');", asset_type=JS).create() 219 | 220 | base_template = templates_dir / "base.html" 221 | base_template.write_text(""" 222 | 223 | 224 | Test 225 | {% bird:css %} 226 | 227 | 228 | {% bird alert %}Warning{% endbird %} 229 | {% block content %}{% endblock %} 230 | {% bird:js %} 231 | 232 | 233 | """) 234 | 235 | include_template = templates_dir / "include.html" 236 | include_template.write_text(""" 237 | {% bird banner %}Include me{% endbird %} 238 | """) 239 | 240 | template = templates_dir / "template.html" 241 | template.write_text(""" 242 | {% extends "base.html" %} 243 | {% block content %} 244 | {% include "include.html" %} 245 | {% bird button %}Click me{% endbird %} 246 | {% endblock %} 247 | """) 248 | 249 | return ExampleTemplate( 250 | base=base_template, 251 | include=include_template, 252 | template=template, 253 | used_components=[ 254 | Component.from_name(alert.name), 255 | Component.from_name(banner.name), 256 | Component.from_name(button.name), 257 | ], 258 | unused_components=[Component.from_name(toast.name)], 259 | ) 260 | 261 | 262 | @pytest.fixture(autouse=True) 263 | def registry(): 264 | from django_bird.components import components 265 | 266 | components.reset() 267 | yield components 268 | components.reset() 269 | -------------------------------------------------------------------------------- /.bin/bump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --quiet 2 | # /// script 3 | # requires-python = ">=3.13" 4 | # dependencies = [ 5 | # "bumpver", 6 | # "typer", 7 | # ] 8 | # /// 9 | from __future__ import annotations 10 | 11 | import re 12 | import subprocess 13 | import sys 14 | from enum import Enum 15 | from pathlib import Path 16 | from typing import Annotated 17 | from typing import Any 18 | 19 | import typer 20 | from typer import Option 21 | 22 | 23 | class CommandRunner: 24 | def __init__(self, dry_run: bool = False): 25 | self.dry_run = dry_run 26 | 27 | def _quote_arg(self, arg: str) -> str: 28 | if " " in arg and not (arg.startswith('"') or arg.startswith("'")): 29 | return f"'{arg}'" 30 | return arg 31 | 32 | def _build_command_args(self, **params: Any) -> str: 33 | args = [] 34 | for key, value in params.items(): 35 | key = key.replace("_", "-") 36 | if isinstance(value, bool) and value: 37 | args.append(f"--{key}") 38 | elif value is not None: 39 | args.extend([f"--{key}", self._quote_arg(str(value))]) 40 | return " ".join(args) 41 | 42 | def run( 43 | self, cmd: str, name: str, *args: str, force_run: bool = False, **params: Any 44 | ) -> str: 45 | command_parts = [cmd, name] 46 | command_parts.extend(self._quote_arg(arg) for arg in args) 47 | if params: 48 | command_parts.append(self._build_command_args(**params)) 49 | command = " ".join(command_parts) 50 | print( 51 | f"would run command: {command}" 52 | if self.dry_run and not force_run 53 | else f"running command: {command}" 54 | ) 55 | 56 | if self.dry_run and not force_run: 57 | return "" 58 | 59 | success, output = self._run_command(command) 60 | if not success: 61 | print(f"{cmd} failed: {output}", file=sys.stderr) 62 | raise typer.Exit(1) 63 | return output 64 | 65 | def _run_command(self, command: str) -> tuple[bool, str]: 66 | try: 67 | output = subprocess.check_output( 68 | command, shell=True, text=True, stderr=subprocess.STDOUT 69 | ).strip() 70 | return True, output 71 | except subprocess.CalledProcessError as e: 72 | return False, e.output 73 | 74 | 75 | _runner: CommandRunner | None = None 76 | 77 | 78 | def run(cmd: str, name: str, *args: str, **params: Any) -> str: 79 | if _runner is None: 80 | raise RuntimeError("CommandRunner not initialized. Call init_runner first.") 81 | return _runner.run(cmd, name, *args, **params) 82 | 83 | 84 | def init_runner(dry_run: bool = False) -> None: 85 | global _runner 86 | _runner = CommandRunner(dry_run) 87 | 88 | 89 | def get_current_version(): 90 | tags = run("git", "tag", "--sort=-creatordate", force_run=True).splitlines() 91 | return tags[0] if tags else "" 92 | 93 | 94 | def get_new_version(version: Version, tag: Tag | None = None) -> str: 95 | output = run( 96 | "bumpver", "update", dry=True, tag=tag, force_run=True, **{version: True} 97 | ) 98 | if match := re.search(r"New Version: (.+)", output): 99 | return match.group(1) 100 | return typer.prompt("Failed to get new version. Enter manually") 101 | 102 | 103 | def get_release_version() -> str: 104 | log = run( 105 | "git", 106 | "log", 107 | "-1", 108 | "--pretty=format:%s", 109 | force_run=True, 110 | ) 111 | if match := re.search(r"bump version .* -> ([\d.]+)", log): 112 | return match.group(1) 113 | print("Could not find version in latest commit message") 114 | raise typer.Exit(1) 115 | 116 | 117 | def update_changelog(new_version: str) -> None: 118 | repo_url = run("git", "remote", "get-url", "origin").strip().replace(".git", "") 119 | changelog = Path("CHANGELOG.md") 120 | content = changelog.read_text() 121 | 122 | content = re.sub( 123 | r"## \[Unreleased\]", 124 | f"## [{new_version}]", 125 | content, 126 | count=1, 127 | ) 128 | content = re.sub( 129 | rf"## \[{new_version}\]", 130 | f"## [Unreleased]\n\n## [{new_version}]", 131 | content, 132 | count=1, 133 | ) 134 | content += f"[{new_version}]: {repo_url}/releases/tag/v{new_version}\n" 135 | content = re.sub( 136 | r"\[unreleased\]: .*\n", 137 | f"[unreleased]: {repo_url}/compare/v{new_version}...HEAD\n", 138 | content, 139 | count=1, 140 | ) 141 | 142 | changelog.write_text(content) 143 | run("git", "add", ".") 144 | run("git", "commit", "-m", f"update CHANGELOG for version {new_version}") 145 | 146 | 147 | def update_uv_lock(new_version: str) -> None: 148 | run("uv", "lock") 149 | 150 | changes = run("git", "status", "--porcelain", force_run=True) 151 | if "uv.lock" not in changes: 152 | print("No changes to uv.lock, skipping commit") 153 | return 154 | 155 | run("git", "add", "uv.lock") 156 | run("git", "commit", "-m", f"update uv.lock for version {new_version}") 157 | 158 | 159 | cli = typer.Typer() 160 | 161 | 162 | class Version(str, Enum): 163 | MAJOR = "major" 164 | MINOR = "minor" 165 | PATCH = "patch" 166 | 167 | 168 | class Tag(str, Enum): 169 | DEV = "dev" 170 | ALPHA = "alpha" 171 | BETA = "beta" 172 | RC = "rc" 173 | FINAL = "final" 174 | 175 | 176 | @cli.command() 177 | def version( 178 | version: Annotated[ 179 | Version, Option("--version", "-v", help="The tag to add to the new version") 180 | ], 181 | tag: Annotated[Tag, Option("--tag", "-t", help="The tag to add to the new version")] 182 | | None = None, 183 | dry_run: Annotated[ 184 | bool, Option("--dry-run", "-d", help="Show commands without executing") 185 | ] = False, 186 | ): 187 | init_runner(dry_run) 188 | 189 | current_version = get_current_version() 190 | changes = run( 191 | "git", 192 | "log", 193 | f"{current_version}..HEAD", 194 | "--pretty=format:- `%h`: %s", 195 | "--reverse", 196 | force_run=True, 197 | ) 198 | 199 | new_version = get_new_version(version, tag) 200 | release_branch = f"release-v{new_version}" 201 | 202 | try: 203 | run("git", "checkout", "-b", release_branch) 204 | except Exception: 205 | run("git", "checkout", release_branch) 206 | 207 | run("bumpver", "update", tag=tag, **{version: True}) 208 | 209 | title = run("git", "log", "-1", "--pretty=%s") 210 | 211 | update_changelog(new_version) 212 | update_uv_lock(new_version) 213 | 214 | run("git", "push", "--set-upstream", "origin", release_branch) 215 | run( 216 | "gh", 217 | "pr", 218 | "create", 219 | "--base", 220 | "main", 221 | "--head", 222 | release_branch, 223 | "--title", 224 | title, 225 | "--body", 226 | changes, 227 | ) 228 | 229 | 230 | @cli.command() 231 | def release( 232 | dry_run: Annotated[ 233 | bool, Option("--dry-run", "-d", help="Show commands without executing") 234 | ] = False, 235 | force: Annotated[bool, Option("--force", "-f", help="Skip safety checks")] = False, 236 | ): 237 | init_runner(dry_run) 238 | 239 | current_branch = run("git", "branch", "--show-current", force_run=True).strip() 240 | if current_branch != "main" and not force: 241 | print( 242 | f"Must be on main branch to create release (currently on {current_branch})" 243 | ) 244 | raise typer.Exit(1) 245 | 246 | if run("git", "status", "--porcelain") and not force: 247 | print("Working directory is not clean. Commit or stash changes first.") 248 | raise typer.Exit(1) 249 | 250 | run("git", "fetch", "origin", "main") 251 | local_sha = run("git", "rev-parse", "@").strip() 252 | remote_sha = run("git", "rev-parse", "@{u}").strip() 253 | if local_sha != remote_sha and not force: 254 | print("Local main is not up to date with remote. Pull changes first.") 255 | raise typer.Exit(1) 256 | 257 | version = get_release_version() 258 | 259 | try: 260 | run("gh", "release", "view", f"v{version}") 261 | if not force: 262 | print(f"Release v{version} already exists!") 263 | raise typer.Exit(1) 264 | except Exception: 265 | pass 266 | 267 | if not force and not dry_run: 268 | typer.confirm(f"Create release v{version}?", abort=True) 269 | 270 | run("gh", "release", "create", f"v{version}", "--generate-notes") 271 | 272 | 273 | if __name__ == "__main__": 274 | cli() 275 | -------------------------------------------------------------------------------- /src/django_bird/components.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from collections import defaultdict 5 | from collections.abc import Generator 6 | from collections.abc import Iterable 7 | from dataclasses import dataclass 8 | from dataclasses import field 9 | from hashlib import md5 10 | from pathlib import Path 11 | from threading import Lock 12 | from typing import Any 13 | 14 | from django.conf import settings 15 | from django.template.backends.django import Template as DjangoTemplate 16 | from django.template.base import Node 17 | from django.template.base import NodeList 18 | from django.template.base import TextNode 19 | from django.template.context import Context 20 | from django.template.loader import select_template 21 | 22 | from .conf import app_settings 23 | from .params import Param 24 | from .params import Params 25 | from .params import Value 26 | from .plugins import pm 27 | from .staticfiles import Asset 28 | from .staticfiles import AssetType 29 | from .templates import find_components_in_template 30 | from .templates import get_component_directories 31 | from .templates import get_template_names 32 | from .templatetags.tags.bird import BirdNode 33 | from .templatetags.tags.slot import DEFAULT_SLOT 34 | from .templatetags.tags.slot import SlotNode 35 | 36 | 37 | @dataclass(frozen=True, slots=True) 38 | class Component: 39 | name: str 40 | template: DjangoTemplate 41 | assets: frozenset[Asset] = field(default_factory=frozenset) 42 | 43 | def get_asset(self, asset_filename: str) -> Asset | None: 44 | for asset in self.assets: 45 | if asset.path.name == asset_filename: 46 | return asset 47 | return None 48 | 49 | def get_bound_component(self, node: BirdNode): 50 | params = Params.from_node(node) 51 | return BoundComponent(component=self, params=params, nodelist=node.nodelist) 52 | 53 | @property 54 | def data_attribute_name(self): 55 | return self.name.replace(".", "-") 56 | 57 | @property 58 | def id(self): 59 | normalized_source = "".join(self.source.split()) 60 | hashed = md5( 61 | f"{self.name}:{self.path}:{normalized_source}".encode() 62 | ).hexdigest() 63 | return hashed[:7] 64 | 65 | @property 66 | def nodelist(self): 67 | return self.template.template.nodelist 68 | 69 | @property 70 | def path(self): 71 | return self.template.template.origin.name 72 | 73 | @property 74 | def source(self): 75 | return self.template.template.source 76 | 77 | @classmethod 78 | def from_abs_path(cls, path: Path) -> Component: 79 | template = select_template([str(path)]) 80 | return cls.from_template(template) 81 | 82 | @classmethod 83 | def from_name(cls, name: str) -> Component: 84 | template_names = get_template_names(name) 85 | template = select_template(template_names) 86 | return cls.from_template(template) 87 | 88 | @classmethod 89 | def from_template(cls, template: DjangoTemplate) -> Component: 90 | template_path = Path(template.template.origin.name) 91 | 92 | for component_dir in get_component_directories(): 93 | try: 94 | relative_path = template_path.relative_to(component_dir) 95 | name = str(relative_path.with_suffix("")).replace("/", ".") 96 | break 97 | except ValueError: 98 | continue 99 | else: 100 | name = template_path.stem 101 | 102 | assets: list[Iterable[Asset]] = pm.hook.collect_component_assets( 103 | template_path=Path(template.template.origin.name) 104 | ) 105 | 106 | return cls( 107 | name=name, template=template, assets=frozenset(itertools.chain(*assets)) 108 | ) 109 | 110 | 111 | class SequenceGenerator: 112 | _instance: SequenceGenerator | None = None 113 | _lock: Lock = Lock() 114 | _counters: dict[str, int] 115 | 116 | def __init__(self) -> None: 117 | if not hasattr(self, "_counters"): 118 | self._counters = {} 119 | 120 | def __new__(cls) -> SequenceGenerator: 121 | if cls._instance is None: 122 | with cls._lock: 123 | if cls._instance is None: 124 | cls._instance = super().__new__(cls) 125 | return cls._instance 126 | 127 | def next(self, component: Component) -> int: 128 | with self._lock: 129 | current = self._counters.get(component.id, 0) + 1 130 | self._counters[component.id] = current 131 | return current 132 | 133 | 134 | @dataclass 135 | class BoundComponent: 136 | component: Component 137 | params: Params 138 | nodelist: NodeList | None 139 | _sequence: SequenceGenerator = field(default_factory=SequenceGenerator) 140 | 141 | def render(self, context: Context): 142 | if app_settings.ENABLE_BIRD_ATTRS: 143 | data_attrs = [ 144 | Param( 145 | f"data-bird-{self.component.data_attribute_name}", 146 | Value(True), 147 | ), 148 | Param("data-bird-id", Value(f'"{self.component.id}-{self.id}"')), 149 | ] 150 | self.params.attrs.extend(data_attrs) 151 | 152 | props = self.params.render_props(self.component, context) 153 | attrs = self.params.render_attrs(context) 154 | slots = self.fill_slots(context) 155 | 156 | with context.push( 157 | **{ 158 | "attrs": attrs, 159 | "props": props, 160 | "slot": slots.get(DEFAULT_SLOT), 161 | "slots": slots, 162 | "vars": {}, 163 | } 164 | ): 165 | return self.component.template.template.render(context) 166 | 167 | def fill_slots(self, context: Context): 168 | if self.nodelist is None: 169 | return { 170 | DEFAULT_SLOT: None, 171 | } 172 | 173 | slot_nodes = { 174 | node.name: node for node in self.nodelist if isinstance(node, SlotNode) 175 | } 176 | default_nodes = NodeList( 177 | [node for node in self.nodelist if not isinstance(node, SlotNode)] 178 | ) 179 | 180 | slots: dict[str, Node | NodeList] = { 181 | DEFAULT_SLOT: default_nodes, 182 | **slot_nodes, 183 | } 184 | 185 | if not slots[DEFAULT_SLOT] and "slot" in context: 186 | slots[DEFAULT_SLOT] = TextNode(context["slot"]) 187 | 188 | return {name: node.render(context) for name, node in slots.items() if node} 189 | 190 | @property 191 | def id(self): 192 | return str(self._sequence.next(self.component)) 193 | 194 | 195 | class ComponentRegistry: 196 | def __init__(self): 197 | self._component_usage: dict[str, set[Path]] = defaultdict(set) 198 | self._components: dict[str, Component] = {} 199 | self._template_usage: dict[Path, set[str]] = defaultdict(set) 200 | 201 | def reset(self) -> None: 202 | """Reset the registry, used for testing.""" 203 | self._component_usage = defaultdict(set) 204 | self._components = {} 205 | self._template_usage = defaultdict(set) 206 | 207 | def get_assets(self, asset_type: AssetType | None = None) -> frozenset[Asset]: 208 | return frozenset( 209 | asset 210 | for component in self._components.values() 211 | for asset in component.assets 212 | if asset_type is None or asset.type == asset_type 213 | ) 214 | 215 | def get_component(self, name: str) -> Component: 216 | if name in self._components and not settings.DEBUG: 217 | return self._components[name] 218 | 219 | self._components[name] = Component.from_name(name) 220 | if name not in self._component_usage: 221 | self._component_usage[name] = set() 222 | return self._components[name] 223 | 224 | def get_component_names_used_in_template( 225 | self, template_path: str | Path 226 | ) -> set[str]: 227 | """Get names of components used in a template.""" 228 | 229 | path = Path(template_path) 230 | 231 | if path in self._template_usage: 232 | return self._template_usage[path] 233 | 234 | components = find_components_in_template(template_path) 235 | 236 | self._template_usage[path] = components 237 | for component_name in components: 238 | self._component_usage[component_name].add(path) 239 | 240 | return components 241 | 242 | def get_component_usage( 243 | self, template_path: str | Path 244 | ) -> Generator[Component, Any, None]: 245 | """Get components used in a template.""" 246 | for component_name in self.get_component_names_used_in_template(template_path): 247 | yield Component.from_name(component_name) 248 | 249 | 250 | components = ComponentRegistry() 251 | -------------------------------------------------------------------------------- /src/django_bird/templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import multiprocessing 5 | from collections.abc import Callable 6 | from collections.abc import Generator 7 | from collections.abc import Iterator 8 | from itertools import chain 9 | from multiprocessing import Pool 10 | from pathlib import Path 11 | from typing import Any 12 | from typing import TypeGuard 13 | from typing import final 14 | 15 | from django.template.base import Node 16 | from django.template.base import Template 17 | from django.template.context import Context 18 | from django.template.engine import Engine 19 | from django.template.exceptions import TemplateDoesNotExist 20 | from django.template.exceptions import TemplateSyntaxError 21 | from django.template.loader_tags import ExtendsNode 22 | from django.template.loader_tags import IncludeNode 23 | from django.template.utils import get_app_template_dirs 24 | 25 | from django_bird import hookimpl 26 | 27 | from .conf import app_settings 28 | from .templatetags.tags.bird import BirdNode 29 | from .utils import get_files_from_dirs 30 | from .utils import unique_ordered 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | def get_template_names(name: str) -> list[str]: 36 | """ 37 | Generate a list of potential template names for a component. 38 | 39 | The function searches for templates in the following order (from most specific to most general): 40 | 41 | 1. In a subdirectory named after the component, using the component name 42 | 2. In the same subdirectory, using a fallback 'index.html' 43 | 3. In parent directory for nested components 44 | 4. In the base component directory, using the full component name 45 | 46 | The order of names is important as it determines the template resolution priority. 47 | This order allows for both direct matches and hierarchical component structures, 48 | with more specific paths taking precedence over more general ones. 49 | 50 | This order allows for: 51 | - Single file components 52 | - Multi-part components 53 | - Specific named files within component directories 54 | - Fallback default files for components 55 | 56 | For example: 57 | - For an "input" component, the ordering would be: 58 | 1. `{component_dir}/input/input.html` 59 | 2. `{component_dir}/input/index.html` 60 | 3. `{component_dir}/input.html` 61 | - For an "input.label" component: 62 | 1. `{component_dir}/input/label/label.html` 63 | 2. `{component_dir}/input/label/index.html` 64 | 3. `{component_dir}/input/label.html` 65 | 4. `{component_dir}/input.label.html` 66 | 67 | Returns: 68 | list[str]: A list of potential template names in resolution order. 69 | """ 70 | template_names: list[str] = [] 71 | component_dirs = get_component_directory_names() 72 | 73 | name_parts = name.split(".") 74 | path_name = "/".join(name_parts) 75 | 76 | for component_dir in component_dirs: 77 | potential_names = [ 78 | f"{component_dir}/{path_name}/{name_parts[-1]}.html", 79 | f"{component_dir}/{path_name}/index.html", 80 | f"{component_dir}/{path_name}.html", 81 | f"{component_dir}/{name}.html", 82 | ] 83 | template_names.extend(potential_names) 84 | 85 | return unique_ordered(template_names) 86 | 87 | 88 | @hookimpl(specname="get_template_directories") 89 | def get_default_engine_directories() -> list[Path]: 90 | engine = Engine.get_default() 91 | return [Path(dir) for dir in engine.dirs] 92 | 93 | 94 | @hookimpl(specname="get_template_directories") 95 | def get_app_template_directories() -> list[Path]: 96 | return [Path(dir) for dir in get_app_template_dirs("templates")] 97 | 98 | 99 | def get_template_directories() -> Generator[Path, Any, None]: 100 | from django_bird.plugins import pm 101 | 102 | for hook_result in pm.hook.get_template_directories(): 103 | yield from hook_result 104 | 105 | 106 | def get_component_directory_names() -> list[Path | str]: 107 | return unique_ordered([*app_settings.COMPONENT_DIRS, "bird"]) 108 | 109 | 110 | def get_component_directories( 111 | template_dirs: Iterator[Path] | None = None, 112 | ) -> list[Path]: 113 | if template_dirs is None: 114 | template_dirs = get_template_directories() 115 | 116 | return [ 117 | Path(template_dir) / component_dir 118 | for template_dir in template_dirs 119 | for component_dir in get_component_directory_names() 120 | ] 121 | 122 | 123 | def gather_bird_tag_template_usage() -> Generator[tuple[Path, set[str]], Any, None]: 124 | template_dirs = get_template_directories() 125 | templates = list(get_files_from_dirs(template_dirs)) 126 | chunk_size = max(1, len(templates) // multiprocessing.cpu_count() * 2) 127 | chunks = [ 128 | templates[i : i + chunk_size] for i in range(0, len(templates), chunk_size) 129 | ] 130 | with Pool() as pool: 131 | results = pool.map(_process_template_chunk, chunks) 132 | yield from chain.from_iterable(results) 133 | 134 | 135 | def _process_template_chunk( # pragma: no cover 136 | templates: list[tuple[Path, Path]], 137 | ) -> list[tuple[Path, set[str]]]: 138 | results: list[tuple[Path, set[str]]] = [] 139 | for path, root in templates: 140 | template_name = str(path.relative_to(root)) 141 | components = find_components_in_template(template_name) 142 | if components: 143 | results.append((path, components)) 144 | return results 145 | 146 | 147 | def find_components_in_template(template_path: str | Path) -> set[str]: 148 | """Find all component names used in a specific template. 149 | 150 | Args: 151 | template_path: Path to the template file or template name 152 | 153 | Returns: 154 | set[str]: Set of component names used in the template 155 | """ 156 | template_name = str(template_path) 157 | 158 | visitor = NodeVisitor(Engine.get_default()) 159 | try: 160 | template = Engine.get_default().get_template(template_name) 161 | context = Context() 162 | with context.bind_template(template): 163 | visitor.visit(template, context) 164 | return visitor.components 165 | except (TemplateDoesNotExist, TemplateSyntaxError, UnicodeDecodeError) as e: 166 | # If we can't load or process the template for any reason, log the exception and return an empty set 167 | logger.debug( 168 | f"Could not process template {template_name!r}: {e.__class__.__name__}: {e}" 169 | ) 170 | return set() 171 | 172 | 173 | NodeVisitorMethod = Callable[[Template | Node, Context], None] 174 | 175 | 176 | def has_nodelist(node: Template | Node) -> TypeGuard[Template]: 177 | return hasattr(node, "nodelist") 178 | 179 | 180 | @final 181 | class NodeVisitor: # pragma: no cover 182 | def __init__(self, engine: Engine): 183 | self.engine = engine 184 | self.components: set[str] = set() 185 | self.visited_templates: set[str] = set() 186 | 187 | def visit(self, node: Template | Node, context: Context) -> None: 188 | method_name = f"visit_{node.__class__.__name__}" 189 | visitor: NodeVisitorMethod = getattr(self, method_name, self.generic_visit) 190 | return visitor(node, context) 191 | 192 | def generic_visit(self, node: Template | Node, context: Context) -> None: 193 | if not has_nodelist(node) or node.nodelist is None: 194 | return 195 | for child_node in node.nodelist: 196 | self.visit(child_node, context) 197 | 198 | def visit_Template(self, template: Template, context: Context) -> None: 199 | if template.name is None or template.name in self.visited_templates: 200 | return 201 | self.visited_templates.add(template.name) 202 | self.generic_visit(template, context) 203 | 204 | def visit_BirdNode(self, node: BirdNode, context: Context) -> None: 205 | component_name = node.name.strip("\"'") 206 | self.components.add(component_name) 207 | self.generic_visit(node, context) 208 | 209 | def visit_ExtendsNode(self, node: ExtendsNode, context: Context) -> None: 210 | parent_template = node.get_parent(context) 211 | self.visit(parent_template, context) 212 | self.generic_visit(node, context) 213 | 214 | def visit_IncludeNode(self, node: IncludeNode, context: Context) -> None: 215 | try: 216 | included_templates = node.template.resolve(context) 217 | if not isinstance(included_templates, list | tuple): 218 | included_templates = [included_templates] 219 | for template_name in included_templates: 220 | included_template = self.engine.get_template(template_name) 221 | self.visit(included_template, context) 222 | except Exception as e: 223 | logger.debug( 224 | f"Error processing included template in NodeVisitor: {e.__class__.__name__}: {e}" 225 | ) 226 | self.generic_visit(node, context) 227 | -------------------------------------------------------------------------------- /tests/templatetags/test_var.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django import template 5 | from django.template import Context 6 | from django.template import Template 7 | from django.template.base import Parser 8 | from django.template.base import Token 9 | from django.template.base import TokenType 10 | from django.template.exceptions import TemplateSyntaxError 11 | 12 | from django_bird.templatetags.tags.var import END_TAG 13 | from django_bird.templatetags.tags.var import TAG 14 | from django_bird.templatetags.tags.var import do_end_var 15 | from django_bird.templatetags.tags.var import do_var 16 | from tests.utils import TestComponent 17 | 18 | 19 | def test_do_var_no_args(): 20 | start_token = Token(TokenType.BLOCK, TAG) 21 | 22 | with pytest.raises(template.TemplateSyntaxError): 23 | do_var(Parser([]), start_token) 24 | 25 | 26 | def test_do_end_var_no_args(): 27 | start_token = Token(TokenType.BLOCK, END_TAG) 28 | 29 | with pytest.raises(template.TemplateSyntaxError): 30 | do_end_var(Parser([]), start_token) 31 | 32 | 33 | def test_basic_assignment(): 34 | template = Template(""" 35 | {% load django_bird %} 36 | {% bird:var x='hello' %} 37 | {{ vars.x }} 38 | """) 39 | 40 | rendered = template.render(Context({})) 41 | 42 | assert rendered.strip() == "hello" 43 | 44 | 45 | def test_append_to_variable(): 46 | template = Template(""" 47 | {% load django_bird %} 48 | {% bird:var x='hello' %} 49 | {% bird:var x+=' world' %} 50 | {{ vars.x }} 51 | """) 52 | 53 | rendered = template.render(Context({})) 54 | 55 | assert rendered.strip() == "hello world" 56 | 57 | 58 | def test_multiple_variables(): 59 | template = Template(""" 60 | {% load django_bird %} 61 | {% bird:var x='hello' %} 62 | {% bird:var y=' world' %} 63 | {{ vars.x }}{{ vars.y }} 64 | """) 65 | 66 | rendered = template.render(Context({})) 67 | 68 | assert rendered.strip() == "hello world" 69 | 70 | 71 | def test_append_to_nonexistent_variable(): 72 | template = Template(""" 73 | {% load django_bird %} 74 | {% bird:var x+='world' %} 75 | {{ vars.x }} 76 | """) 77 | 78 | rendered = template.render(Context({})) 79 | 80 | assert rendered.strip() == "world" 81 | 82 | 83 | def test_variable_with_template_variable(): 84 | template = Template(""" 85 | {% load django_bird %} 86 | {% bird:var greeting='Hello ' %} 87 | {% bird:var greeting+=name %} 88 | {{ vars.greeting }} 89 | """) 90 | 91 | rendered = template.render(Context({"name": "Django"})) 92 | 93 | assert rendered.strip() == "Hello Django" 94 | 95 | 96 | def test_explicit_var_cleanup(): 97 | template = Template(""" 98 | {% load django_bird %} 99 | {% bird:var x='hello' %} 100 | Before: {{ vars.x }} 101 | {% endbird:var x %} 102 | After: {{ vars.x|default:'cleaned' }} 103 | """) 104 | 105 | rendered = template.render(Context({})) 106 | 107 | assert "Before: hello" in rendered 108 | assert "After: cleaned" in rendered 109 | 110 | 111 | def test_reseting_var(): 112 | template = Template(""" 113 | {% load django_bird %} 114 | {% bird:var x='hello' %} 115 | Before: {{ vars.x }} 116 | {% bird:var x=None %} 117 | After: {{ vars.x|default:'cleaned' }} 118 | """) 119 | 120 | rendered = template.render(Context({})) 121 | 122 | assert "Before: hello" in rendered 123 | assert "After: cleaned" in rendered 124 | 125 | 126 | def test_explicit_var_cleanup_with_multiple_vars(): 127 | template = Template(""" 128 | {% load django_bird %} 129 | {% bird:var x='hello' %} 130 | {% bird:var y='world' %} 131 | Before: {{ vars.x }} {{ vars.y }} 132 | {% endbird:var x %} 133 | Middle: {{ vars.x|default:'cleaned' }} {{ vars.y }} 134 | {% endbird:var y %} 135 | After: {{ vars.x|default:'cleaned' }} {{ vars.y|default:'cleaned' }} 136 | """) 137 | 138 | rendered = template.render(Context({})) 139 | 140 | assert "Before: hello world" in rendered 141 | assert "Middle: cleaned world" in rendered 142 | assert "After: cleaned cleaned" in rendered 143 | 144 | 145 | @pytest.mark.parametrize( 146 | ("template_str", "expected_error"), 147 | [ 148 | ( 149 | "{% load django_bird %}{% bird:var %}", 150 | r"\'bird:var\' tag requires an assignment", 151 | ), 152 | ( 153 | "{% load django_bird %}{% bird:var invalid_syntax %}", 154 | r"Invalid assignment in \'bird:var\' tag: invalid_syntax\. Expected format: bird:var variable=\'value\' or bird:var variable\+=\'value\'\.", 155 | ), 156 | ], 157 | ) 158 | def test_syntax_errors(template_str: str, expected_error: str): 159 | with pytest.raises(TemplateSyntaxError, match=expected_error): 160 | Template(template_str) 161 | 162 | 163 | def test_var_context_isolation_between_components(create_template, templates_dir): 164 | TestComponent( 165 | name="button", 166 | content=""" 167 | {% bird:var x='button1' %} 168 | {{ vars.x }} 169 | """, 170 | ).create(templates_dir) 171 | 172 | template_path = templates_dir / "test.html" 173 | template_path.write_text(""" 174 | {% bird button %}{% endbird %} 175 | {% bird button %}{% endbird %} 176 | """) 177 | template = create_template(template_path) 178 | 179 | rendered = template.render({}) 180 | 181 | assert "button1" in rendered 182 | assert rendered.count("button1") == 2 183 | 184 | 185 | def test_var_context_does_not_leak_outside_component(create_template, templates_dir): 186 | TestComponent( 187 | name="button", 188 | content=""" 189 | {% bird:var x='button1' %} 190 | Inner: {{ vars.x }} 191 | """, 192 | ).create(templates_dir) 193 | 194 | template_path = templates_dir / "test.html" 195 | template_path.write_text(""" 196 | {% bird button %}{% endbird %} 197 | Outer: {{ vars.x|default:'not accessible' }} 198 | """) 199 | template = create_template(template_path) 200 | 201 | rendered = template.render({}) 202 | 203 | assert "Inner: button1" in rendered 204 | assert "Outer: not accessible" in rendered 205 | 206 | 207 | def test_var_context_isolation_nested_components(create_template, templates_dir): 208 | TestComponent( 209 | name="outer", 210 | content=""" 211 | {% bird:var x='outer' %} 212 | Outer: {{ vars.x }} 213 | {% bird inner %}{% endbird %} 214 | After Inner: {{ vars.x }} 215 | """, 216 | ).create(templates_dir) 217 | TestComponent( 218 | name="inner", 219 | content=""" 220 | {% bird:var x='inner' %} 221 | Inner: {{ vars.x }} 222 | """, 223 | ).create(templates_dir) 224 | 225 | template_path = templates_dir / "test.html" 226 | template_path.write_text(""" 227 | {% bird outer %}{% endbird %} 228 | """) 229 | template = create_template(template_path) 230 | 231 | rendered = template.render({}) 232 | 233 | assert "Outer: outer" in rendered 234 | assert "Inner: inner" in rendered 235 | assert "After Inner: outer" in rendered 236 | 237 | 238 | def test_var_context_clean_between_renders(create_template, templates_dir): 239 | TestComponent( 240 | name="counter", 241 | content=""" 242 | {% bird:var count='1' %} 243 | Count: {{ vars.count }} 244 | """, 245 | ).create(templates_dir) 246 | 247 | template_path = templates_dir / "test.html" 248 | template_path.write_text("{% bird counter %}{% endbird %}") 249 | template = create_template(template_path) 250 | 251 | first_render = template.render({}) 252 | second_render = template.render({}) 253 | 254 | assert "Count: 1" in first_render 255 | assert "Count: 1" in second_render 256 | 257 | 258 | def test_var_append_with_nested_components(create_template, templates_dir): 259 | TestComponent( 260 | name="outer", 261 | content=""" 262 | {% bird:var message='Hello' %} 263 | {% bird:var message+=' outer' %} 264 | Outer: {{ vars.message }} 265 | {% bird inner %}{% endbird %} 266 | After: {{ vars.message }} 267 | """, 268 | ).create(templates_dir) 269 | TestComponent( 270 | name="inner", 271 | content=""" 272 | {% bird:var message='Hello' %} 273 | {% bird:var message+=' inner' %} 274 | Inner: {{ vars.message }} 275 | """, 276 | ).create(templates_dir) 277 | 278 | template_path = templates_dir / "test.html" 279 | template_path.write_text("{% bird outer %}{% endbird %}") 280 | template = create_template(template_path) 281 | 282 | rendered = template.render({}) 283 | 284 | assert "Outer: Hello outer" in rendered 285 | assert "Inner: Hello inner" in rendered 286 | assert "After: Hello outer" in rendered 287 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # django-bird 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/django-bird)](https://pypi.org/project/django-bird/) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-bird) 6 | ![Django Version](https://img.shields.io/badge/django-4.2%20%7C%205.0%20%7C%205.1%20%7C%205.2-%2344B78B?labelColor=%23092E20) 7 | 8 | 9 | 10 | 11 | High-flying components for perfectionists with deadlines. 12 | 13 | 14 | > [!IMPORTANT] 15 | > This library is in active development. Breaking changes may occur before v1.0.0. Use caution in production - pin your dependencies and test thoroughly. All changes, including breaking changes, are documented in the [CHANGELOG](CHANGELOG.md). 16 | 17 | 18 | ## Requirements 19 | 20 | - Python 3.10, 3.11, 3.12, 3.13 21 | - Django 4.2, 5.0, 5.1, 5.2 22 | 23 | ## Installation 24 | 25 | 1. Install the package from PyPI: 26 | 27 | ```bash 28 | python -m pip install django-bird 29 | 30 | # or if you like the new hotness 31 | 32 | uv add django-bird 33 | uv sync 34 | ``` 35 | 36 | 2. Add the app to your Django project's `INSTALLED_APPS`: 37 | 38 | ```python 39 | INSTALLED_APPS = [ 40 | ..., 41 | "django_bird", 42 | ..., 43 | ] 44 | ``` 45 | 46 | 3. Set up django-bird in your project settings: 47 | 48 | ```python 49 | # Required: Add the asset finder to handle component assets 50 | STATICFILES_FINDERS = [ 51 | "django.contrib.staticfiles.finders.FileSystemFinder", 52 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 53 | "django_bird.staticfiles.BirdAssetFinder", 54 | ] 55 | 56 | # Optional: Add templatetags to builtins for convenience 57 | # (otherwise you'll need to {% load django_bird %} in each template) 58 | TEMPLATES = [ 59 | { 60 | # ... other settings ... 61 | "OPTIONS": { 62 | "builtins": [ 63 | "django_bird.templatetags.django_bird", 64 | ], 65 | }, 66 | } 67 | ] 68 | ``` 69 | 70 | For detailed instructions, please refer to the [Configuration](https://django-bird.readthedocs.io/configuration.html#configuration) section in the documentation. 71 | 72 | > [!TIP] 73 | > For automatic configuration, you can use the [django-bird-autoconf](https://pypi.org/project/django-bird-autoconf/) plugin. 74 | 75 | ## Getting Started 76 | 77 | django-bird is a library for creating reusable components in Django. Let's create a simple button component to show the basics of how to use the library. 78 | 79 | Create a new directory named `bird` in your project's main templates directory. This will be the primary location for your components. 80 | 81 | ```bash 82 | templates/ 83 | └── bird/ 84 | ``` 85 | 86 | Inside the bird directory, create a new file named `button.html`. The filename determines the component's name. 87 | 88 | ```bash 89 | templates/ 90 | └── bird/ 91 | └── button.html 92 | ``` 93 | 94 | In `button.html`, create a simple HTML button. Use `{{ slot }}` to indicate where the main content will go. We will also define a component property via the `{% bird:prop %}` templatetag and add `{{ attrs }}` for passing in arbitrary HTML attributes. 95 | 96 | ```htmldjango 97 | {# templates/bird/button.html #} 98 | {% bird:prop class="btn" %} 99 | {% bird:prop data_attr="button" %} 100 | 101 | 104 | ``` 105 | 106 | To use your component in a Django template, use the `{% bird %}` templatetag. The content between `{% bird %}` and `{% endbird %}` becomes the `{{ slot }}` content. Properties and attributes are set as parameters on the `{% bird %}` tag itself. 107 | 108 | ```htmldjango 109 | {% bird button class="btn-primary" disabled=True %} 110 | Click me! 111 | {% endbird %} 112 | ``` 113 | 114 | django-bird automatically recognizes components in the bird directory, so no manual registration is needed. When Django processes the template, django-bird replaces the `{% bird %}` tag with the component's HTML, inserting the provided content into the slot, resulting in: 115 | 116 | ```html 117 | 120 | ``` 121 | 122 | You now have a button component that can be easily reused across your Django project. 123 | 124 | 125 | ## Documentation 126 | 127 | django-bird offers features for creating flexible components, such as: 128 | 129 | - [Defining and registering components](https://django-bird.readthedocs.io/en/latest/naming.html) entirely within Django templates, without writing a custom templatetag 130 | - Passing [attributes and properties](https://django-bird.readthedocs.io/en/latest/params.html) to components 131 | - [Named slots](https://django-bird.readthedocs.io/en/latest/slots.html#named-slots) for organizing content within components 132 | - [Subcomponents](https://django-bird.readthedocs.io/en/latest/organization.html) for building complex component structures 133 | - Automatic [asset management](https://django-bird.readthedocs.io/en/latest/assets.html) for component CSS and JavaScript files 134 | 135 | For a full overview of the features and configuration options, please refer to the [documentation](https://django-bird.readthedocs.io). 136 | 137 | ## Motivation 138 | 139 | 140 | Several excellent libraries for creating components in Django exist: 141 | 142 | - [django-components](https://github.com/EmilStenstrom/django-components) 143 | - [django-cotton](https://github.com/wrabit/django-cotton) 144 | - [django-unicorn](https://github.com/adamghill/django-unicorn) 145 | - [django-viewcomponent](https://github.com/rails-inspire-django/django-viewcomponent) 146 | - [django-web-components](https://github.com/Xzya/django-web-components) 147 | - [slippers](https://github.com/mixxorz/slippers) 148 | 149 | > [!NOTE] 150 | > Also worth mentioning is [django-template-partials](https://github.com/carltongibson/django-template-partials) from Carlton Gibson. While not a full component library, it allows defining reusable chunks in a Django template, providing a lightweight approach to reusability. 151 | 152 | These libraries are excellent in their own right, each solving specific problems in innovative ways: django-components is full-featured and will take most people far with custom components, django-unicorn offers a novel approach to adding interactivity without a full JavaScript framework, and django-cotton has a new way of defining custom components that has me very excited. 153 | 154 | **So, why another Django component library?** 155 | 156 | Most of the ones above focus on defining components on the Python side, which works for many use cases. For those focusing on the HTML and Django template side, they have made significant strides in improving the developer experience. However, as a developer with strong opinions (sometimes loosely held 😄) about API design, I wanted a different approach. 157 | 158 | After watching Caleb Porzio's [2024 Laracon US talk](https://www.youtube.com/watch?v=31pBMi0UdYE) introducing [Flux](https://fluxui.dev), I could not shake the desire to bring something similar to Django. While there are plenty of libraries such as Shoelace or UI kits designed for use in any web application, and tools like SaaS Pegasus for whole Django project generation, I couldn't find a well-polished component library solely dedicated to Django templates with the level of polish that Flux has for Laravel. 159 | 160 | Initially, I considered contributing to existing libraries or wrapping one to add the functionality I wanted. However, I decided to create a new library for several reasons: 161 | 162 | 1. I wanted to respect the hard work of existing maintainers and avoid burdening them with features that may not align with their project's goals. 163 | 2. While wrapping an existing library might have been technically feasible and okay license-wise, it didn't feel right to build an entire component system on top of someone else's work, especially for a project I might want to develop independently in the future. 164 | 3. Building something new gives me the freedom to fully control the direction and architecture, without being constrained by design choices made in other libraries. 165 | 4. Healthy competition among libraries helps drive innovation, and I see this as an opportunity to contribute to the broader Django ecosystem. 166 | 5. Recent libraries like [django-cotton](https://github.com/wrabit/django-cotton) and [dj-angles](https://github.com/adamghill/dj-angles) are pushing Django templates in new and exciting directions and I wanted to join in on the fun. 😄 167 | 168 | It's very early days for django-bird. What you see here is laying the foundation for a template-centric approach to Django components. The current implementation focuses on core functionality, setting the stage for future features and enhancements. 169 | 170 | 171 | See the [ROADMAP](ROADMAP.md) for planned features and the future direction of django-bird. 172 | 173 | ## License 174 | 175 | `django-bird` is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. 176 | -------------------------------------------------------------------------------- /src/django_bird/staticfiles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from collections.abc import Iterable 6 | from dataclasses import dataclass 7 | from enum import Enum 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING 10 | from typing import Any 11 | from typing import Literal 12 | from typing import final 13 | from typing import overload 14 | 15 | from django.conf import settings 16 | from django.contrib.staticfiles import finders 17 | from django.contrib.staticfiles.finders import BaseFinder 18 | from django.contrib.staticfiles.storage import StaticFilesStorage 19 | from django.core.checks import CheckMessage 20 | from django.core.files.storage import FileSystemStorage 21 | 22 | from django_bird import hookimpl 23 | 24 | from ._typing import override 25 | from .apps import DjangoBirdAppConfig 26 | from .conf import app_settings 27 | from .templates import get_component_directories 28 | from .templates import get_component_directory_names 29 | from .templatetags.tags.asset import AssetTag 30 | from .utils import get_files_from_dirs 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | if TYPE_CHECKING: 35 | from .components import Component 36 | 37 | 38 | class AssetElement(Enum): 39 | STYLESHEET = "stylesheet" 40 | SCRIPT = "script" 41 | 42 | 43 | @dataclass(frozen=True, slots=True) 44 | class AssetType: 45 | element: AssetElement 46 | extension: str 47 | tag: AssetTag 48 | 49 | @property 50 | def suffix(self): 51 | return f".{self.extension}" 52 | 53 | 54 | CSS = AssetType( 55 | element=AssetElement.STYLESHEET, 56 | extension="css", 57 | tag=AssetTag.CSS, 58 | ) 59 | JS = AssetType( 60 | element=AssetElement.SCRIPT, 61 | extension="js", 62 | tag=AssetTag.JS, 63 | ) 64 | 65 | 66 | @final 67 | class AssetTypes: 68 | def __init__(self): 69 | self.types: set[AssetType] = set() 70 | 71 | def is_known_type(self, path: Path) -> bool: 72 | return any(path.suffix == asset_type.suffix for asset_type in self.types) 73 | 74 | def register_type(self, asset_type: AssetType) -> None: 75 | self.types.add(asset_type) 76 | 77 | def reset(self) -> None: 78 | self.types.clear() 79 | 80 | 81 | asset_types = AssetTypes() 82 | 83 | 84 | @hookimpl 85 | def register_asset_types(register_type: Callable[[AssetType], None]): 86 | register_type(CSS) 87 | register_type(JS) 88 | 89 | 90 | @dataclass(frozen=True, slots=True) 91 | class Asset: 92 | path: Path 93 | type: AssetType 94 | 95 | @override 96 | def __hash__(self) -> int: 97 | return hash((str(self.path), self.type)) 98 | 99 | def exists(self) -> bool: 100 | return self.path.exists() 101 | 102 | def render(self): 103 | if self.url is None: 104 | return "" 105 | 106 | match self.type.element: 107 | case AssetElement.STYLESHEET: 108 | return f'' 109 | case AssetElement.SCRIPT: 110 | return f'' 111 | 112 | @property 113 | def absolute_path(self): 114 | return self.path.resolve() 115 | 116 | @property 117 | def relative_path(self): 118 | return self.path.relative_to(self.template_dir) 119 | 120 | @property 121 | def storage(self): 122 | return BirdAssetStorage( 123 | location=str(self.template_dir), prefix=DjangoBirdAppConfig.label 124 | ) 125 | 126 | @property 127 | def template_dir(self): 128 | template_dir = self.path.parent 129 | component_dirs = get_component_directory_names() 130 | while ( 131 | len(template_dir.parts) > 1 and template_dir.parts[-1] not in component_dirs 132 | ): 133 | template_dir = template_dir.parent 134 | return template_dir.parent 135 | 136 | @property 137 | def url(self) -> str | None: 138 | static_path = finders.find(str(self.relative_path)) 139 | if static_path is None: 140 | return None 141 | static_relative_path = Path(static_path).relative_to(self.template_dir) 142 | return self.storage.url(str(static_relative_path)) 143 | 144 | 145 | @hookimpl 146 | def collect_component_assets(template_path: Path) -> Iterable[Asset]: 147 | assets: list[Asset] = [] 148 | for asset_type in asset_types.types: 149 | asset_path = template_path.with_suffix(asset_type.suffix) 150 | if asset_path.exists(): 151 | assets.append(Asset(path=asset_path, type=asset_type)) 152 | return assets 153 | 154 | 155 | def get_component_assets( 156 | component: Component, asset_type: str | None = None 157 | ) -> list[Asset]: 158 | """Get assets for a component, optionally filtered by type. 159 | 160 | Args: 161 | component: The component to get assets for 162 | asset_type: Optional asset type extension to filter by (e.g., "css", "js") 163 | 164 | Returns: 165 | A list of assets for the component, filtered by type if specified 166 | """ 167 | assets = list(component.assets) 168 | if asset_type: 169 | assets = [a for a in assets if a.type.extension == asset_type] 170 | return assets 171 | 172 | 173 | @final 174 | class BirdAssetStorage(StaticFilesStorage): 175 | def __init__(self, *args: Any, prefix: str, **kwargs: Any): 176 | super().__init__(*args, **kwargs) 177 | self.prefix = prefix 178 | 179 | @override 180 | def url(self, name: str | None) -> str: 181 | if name is None: 182 | return super().url(name) 183 | # Add prefix based on app settings configuration 184 | # In development, asset paths don't include the app label prefix 185 | # because they come directly from source directories 186 | # In production, assets are collected to STATIC_ROOT/django_bird/ 187 | add_prefix = ( 188 | app_settings.ADD_ASSET_PREFIX 189 | if app_settings.ADD_ASSET_PREFIX is not None 190 | else not settings.DEBUG 191 | ) 192 | if add_prefix and not name.startswith(f"{self.prefix}/"): 193 | name = f"{self.prefix}/{name}" 194 | return super().url(name) 195 | 196 | 197 | @final 198 | class BirdAssetFinder(BaseFinder): 199 | @override 200 | def check(self, **kwargs: Any) -> list[CheckMessage]: 201 | return [] 202 | 203 | # Django 5.2 changed the argument from `find` to `find_all`, but django-stubs 204 | # (as of the time of this commit) hasn't been updated to reflect this, hence the 205 | # type ignore 206 | @overload # type: ignore[override] 207 | def find(self, path: str, *, all: Literal[False] = False) -> str | None: ... 208 | @overload 209 | def find(self, path: str, *, all: Literal[True]) -> list[str]: ... 210 | @overload 211 | def find(self, path: str, *, find_all: Literal[False] = False) -> str | None: ... 212 | @overload 213 | def find(self, path: str, *, find_all: Literal[True]) -> list[str]: ... 214 | @override 215 | def find( # pyright: ignore[reportIncompatibleMethodOverride] 216 | self, 217 | path: str, 218 | all: bool = False, 219 | find_all: bool | None = None, 220 | ) -> str | list[str] | None: 221 | """ 222 | Given a relative file path, return the absolute path(s) where it can be found. 223 | """ 224 | if find_all is None: 225 | find_all = all 226 | 227 | path_obj = Path(path) 228 | 229 | # check if asset type is registered and check if it's a file 230 | # (allow directories to pass through) 231 | if not asset_types.is_known_type(path_obj) and path_obj.suffix: 232 | return [] 233 | 234 | path_base_dir = path_obj.parts[0] 235 | matches: list[str] = [] 236 | 237 | for component_dir in get_component_directories(): 238 | if component_dir.name == path_base_dir: 239 | asset_path = component_dir / path_obj.relative_to(path_base_dir) 240 | if asset_path.exists(): 241 | matched_path = str(asset_path) 242 | if not find_all: 243 | return matched_path 244 | matches.append(matched_path) 245 | 246 | return matches 247 | 248 | @override 249 | def list( 250 | self, ignore_patterns: Iterable[str] | None 251 | ) -> Iterable[tuple[str, FileSystemStorage]]: 252 | """ 253 | Return (relative_path, storage) pairs for all assets. 254 | 255 | This method is used by Django's collectstatic command to find 256 | all assets that should be collected. 257 | """ 258 | 259 | from django_bird.components import Component 260 | 261 | component_dirs = get_component_directories() 262 | 263 | for path, _ in get_files_from_dirs(component_dirs): 264 | if path.suffix != ".html": 265 | continue 266 | 267 | try: 268 | component = Component.from_abs_path(path) 269 | 270 | for asset in component.assets: 271 | if ignore_patterns and any( 272 | asset.relative_path.match(pattern) 273 | for pattern in set(ignore_patterns) 274 | ): 275 | logger.debug( 276 | f"Skipping asset {asset.path} due to ignore pattern" 277 | ) 278 | continue 279 | 280 | yield str(asset.relative_path), asset.storage 281 | 282 | except Exception as e: 283 | logger.error(f"Error loading component {path}: {e}") 284 | continue 285 | -------------------------------------------------------------------------------- /docs/organization.md: -------------------------------------------------------------------------------- 1 | # Organizing Components 2 | 3 | As components grow in complexity, effective organization becomes crucial. Let's explore approaches, starting from a basic implementation to more sophisticated structures. 4 | 5 | Let's start with a simple icon component and evolve its organization. We'll create a component using the [Heroicons](https://heroicons.com) library. 6 | 7 | ```{note} 8 | This is for demonstration purposes. For real-world applications using Heroicons, consider using Adam Johnson's [heroicon](https://github.com/adamchainz/heroicons) Python package. 9 | ``` 10 | 11 | ## Basic Approach: Single File 12 | 13 | Let's start by including all component variations in a single file: 14 | 15 | ```{code-block} htmldjango 16 | :caption: templates/bird/icon.html 17 | 18 | {% bird:prop name %} 19 | 20 | {% if props.name == "arrow-down" %} 21 | 22 | 23 | 24 | {% elif props.name == "arrow-left" %} 25 | 26 | 27 | 28 | {% elif props.name == "arrow-right" %} 29 | 30 | 31 | 32 | {% elif props.name == "arrow-up" %} 33 | 34 | 35 | 36 | {% endif %} 37 | ``` 38 | 39 | In this example, the icon variant is a passed-in [attribute](attrs.md). Using this component looks like this: 40 | 41 | ```htmldjango 42 | {% bird icon name="arrow-down" / %} 43 | ``` 44 | 45 | ```{tip} 46 | The / at the end of the component definition means it’s a self-closing component. You don’t need a `{% endbird %}` closing tag and can use it for components that don't need to provide any [slots](slots.md) for content. 47 | ``` 48 | 49 | This approach is simple and works for smaller components, but can quickly lead to an unwieldy file as the number of variations increases. Additionally, this does not allow composing different components together. 50 | 51 | ## Alternative Approaches 52 | 53 | Let's explore scalable ways to organize and structure components. 54 | 55 | 1. **Flat Structure** 56 | 57 | Split components into separate files in the same directory. This approach is simple to implement and navigate for a moderate number of components. 58 | 59 | ```bash 60 | templates/bird/ 61 | ├── icon.arrow-down.html 62 | ├── icon.arrow-left.html 63 | ├── icon.arrow-right.html 64 | └── icon.arrow-up.html 65 | ``` 66 | 67 | Bird components take the component name from the filename. Using components this way changes the syntax slightly: 68 | 69 | ```htmldjango 70 | {% bird icon.arrow-down / %} 71 | ``` 72 | 73 | 2. **Dedicated Directories** 74 | 75 | ```bash 76 | templates/bird/ 77 | └── icon/ 78 | ├── arrow-down.html 79 | ├── arrow-left.html 80 | ├── arrow-right.html 81 | └── arrow-up.html 82 | ``` 83 | 84 | This allows better organization as the component count grows, making it easier to locate related components. 85 | 86 | django-bird converts the directory divider into a `.`, so our usage remains unchanged from the flat structure: 87 | 88 | ```htmldjango 89 | {% bird icon.arrow-down / %} 90 | ``` 91 | 92 | 3. **Deeply Nested Directory Structure** 93 | 94 | You can nest the components as deep as required for more granular organization. 95 | 96 | ```bash 97 | templates/bird/ 98 | └── icon/ 99 | └── arrow/ 100 | ├── down.html 101 | ├── left.html 102 | ├── right.html 103 | └── up.html 104 | ``` 105 | 106 | This structure allows for specific categorization, which is useful for large projects with many related component variations. 107 | 108 | Converting the directory structure to component names would change our usage: 109 | 110 | ```htmldjango 111 | {% bird icon.arrow.down / %} 112 | ``` 113 | 114 | ## Real-World Example: Accordion 115 | 116 | Let's examine a complex, real-world example: an accordion component. This component consists of multiple nested parts, demonstrating how our organizational approaches apply to sophisticated structures. 117 | 118 | Here's an example of using our accordion component, based on an MDN [example](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details#multiple_named_disclosure_boxes_): 119 | 120 | ```htmldjango 121 | {% bird accordion %} 122 | {% bird accordion.item name="reqs" %} 123 | {% bird accordion.heading %} 124 | Graduation Requirements 125 | {% endbird %} 126 | {% bird accordion.content %} 127 | Requires 40 credits, including a passing grade in health, geography, 128 | history, economics, and wood shop. 129 | {% endbird %} 130 | {% endbird %} 131 | 132 | {% bird accordion.item name="reqs" %} 133 | {% bird accordion.heading %} 134 | System Requirements 135 | {% endbird %} 136 | {% bird accordion.content %} 137 | Requires a computer running an operating system. The computer must have some 138 | memory and ideally some kind of long-term storage. An input device as well 139 | as some form of output device is recommended. 140 | {% endbird %} 141 | {% endbird %} 142 | 143 | {% bird accordion.item name="reqs" %} 144 | {% bird accordion.heading %} 145 | Job Requirements 146 | {% endbird %} 147 | {% bird accordion.content %} 148 | Requires knowledge of HTML, CSS, JavaScript, accessibility, web performance, 149 | privacy, security, and internationalization, as well as a dislike of 150 | broccoli. 151 | {% endbird %} 152 | {% endbird %} 153 | {% endbird %} 154 | ``` 155 | 156 | Here's how these components might be implemented internally: 157 | 158 | ```{code-block} htmldjango 159 | :caption: `accordion` 160 |
161 | {{ slot }} 162 |
163 | ``` 164 | 165 | ```{code-block} htmldjango 166 | :caption: `accordion.item` 167 |
168 | {{ slot }} 169 |
170 | ``` 171 | 172 | ```{code-block} htmldjango 173 | :caption: `accordion.heading` 174 | 175 | {{ slot }} 176 | 177 | ``` 178 | 179 | ```{code-block} htmldjango 180 | :caption: `accordion.content` 181 |

182 | {{ slot }} 183 |

184 | ``` 185 | 186 | These internal implementations show the accordion’s structure, with slots for content. 187 | 188 | Let's explore different ways to organize the accordion component files: 189 | 190 | 1. In the base of the bird components directory: 191 | 192 | ```bash 193 | templates/bird/ 194 | ├── accordion.html 195 | ├── accordion.heading.html 196 | ├── accordion.item.html 197 | └── accordion.content.html 198 | ``` 199 | 200 | This approach keeps all accordion-related components in the same directory as other components. 201 | 202 | 2. With a dedicated accordion directory: 203 | 204 | ```bash 205 | templates/bird/ 206 | └── accordion/ 207 | ├── accordion.html 208 | ├── heading.html 209 | ├── item.html 210 | └── content.html 211 | ``` 212 | 213 | We group all accordion components in a dedicated directory. 214 | 215 | When using `{% bird accordion %}`, the library will look for components in the following order: 216 | 217 | 1. `accordion/accordion.html` 218 | 2. `accordion/index.html` 219 | 3. `accordion.html` 220 | 221 | This search order allows for more organizational flexibility. 222 | 223 | For example, you can structure your files like this: 224 | 225 | ```bash 226 | templates/bird/ 227 | └── accordion/ 228 | ├── heading.html 229 | ├── index.html # primary accordion component 230 | ├── item.html 231 | └── content.html 232 | ``` 233 | 234 | Or like this: 235 | 236 | ```bash 237 | templates/bird/ 238 | ├── accordion 239 | │ ├── heading.html 240 | │ ├── item.html 241 | │ └── content.html 242 | └── accordion.html 243 | ``` 244 | 245 | This flexibility in naming and organization allows you to use `accordion.html` for explicit naming, `index.html` as a generic entry point, or use a flat structure if neither exists in the accordion directory. 246 | 247 | The component usage in your templates remains the same, regardless of the structure. This approach provides options for organizing components based on project needs and team preferences while maintaining consistent usage patterns. 248 | 249 | ## Choosing the Right Approach 250 | 251 | Each organizational method offers different trade-offs between simplicity and structure. 252 | 253 | The choice depends on various factors: 254 | 255 | - Project size and complexity 256 | - Team size and preferences 257 | - Future scalability needs 258 | - Maintenance ease 259 | 260 | These approaches aren't mutually exclusive. Larger projects might benefit from using a combination of these methods, applying different structures to different parts of the application as needed. 261 | 262 | As your project evolves, you may benefit from refactoring your component organization. Starting with a simpler structure and moving to more complex ones as needed helps maintain clarity and scalability throughout your project's lifecycle. 263 | -------------------------------------------------------------------------------- /tests/templatetags/test_slot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.template import Context 5 | from django.template import Template 6 | from django.template.base import Parser 7 | from django.template.base import Token 8 | from django.template.base import TokenType 9 | from django.template.exceptions import TemplateSyntaxError 10 | 11 | from django_bird.templatetags.tags.slot import DEFAULT_SLOT 12 | from django_bird.templatetags.tags.slot import END_TAG 13 | from django_bird.templatetags.tags.slot import TAG 14 | from django_bird.templatetags.tags.slot import SlotNode 15 | from django_bird.templatetags.tags.slot import do_slot 16 | from tests.utils import TestComponent 17 | from tests.utils import TestComponentCase 18 | from tests.utils import normalize_whitespace 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "contents,expected", 23 | [ 24 | ("", SlotNode(name=DEFAULT_SLOT, nodelist=None)), 25 | ("foo", SlotNode(name="foo", nodelist=None)), 26 | ("'foo'", SlotNode(name="foo", nodelist=None)), 27 | ('"foo"', SlotNode(name="foo", nodelist=None)), 28 | ('name="foo"', SlotNode(name="foo", nodelist=None)), 29 | ("name='foo'", SlotNode(name="foo", nodelist=None)), 30 | ], 31 | ) 32 | def test_parse_slot_name(contents, expected): 33 | start_token = Token(TokenType.BLOCK, f"{TAG} {contents}") 34 | end_token = Token(TokenType.BLOCK, END_TAG) 35 | 36 | node = do_slot(Parser([end_token]), start_token) 37 | 38 | assert node.name == expected.name 39 | 40 | 41 | class TestTemplateTag: 42 | @pytest.mark.parametrize( 43 | "component_content", 44 | [ 45 | "{{ slot }}", 46 | "{% bird:slot %}{% endbird:slot %}", 47 | "{% bird:slot default %}{% endbird:slot %}", 48 | "{% bird:slot 'default' %}{% endbird:slot %}", 49 | '{% bird:slot "default" %}{% endbird:slot %}', 50 | "{% bird:slot name=default %}{% endbird:slot %}", 51 | "{% bird:slot name='default' %}{% endbird:slot %}", 52 | '{% bird:slot name="default" %}{% endbird:slot %}', 53 | ], 54 | ) 55 | def test_default_slot(self, component_content, templates_dir): 56 | test_case = TestComponentCase( 57 | component=TestComponent( 58 | name="test", 59 | content=component_content, 60 | ), 61 | template_content="{% bird test %}Content{% endbird %}", 62 | template_context={"slot": "Content"}, 63 | expected="Content", 64 | ) 65 | test_case.component.create(templates_dir) 66 | 67 | template = Template(test_case.template_content) 68 | rendered = template.render(Context(test_case.template_context)) 69 | 70 | assert normalize_whitespace(rendered) == test_case.expected 71 | 72 | def test_default_content(self, templates_dir): 73 | test_case = TestComponentCase( 74 | component=TestComponent( 75 | name="test", 76 | content=""" 77 | 86 | """, 87 | ), 88 | template_content="{% bird test %}{% endbird %}", 89 | expected="", 90 | ) 91 | test_case.component.create(templates_dir) 92 | 93 | template = Template(test_case.template_content) 94 | rendered = template.render(Context({})) 95 | 96 | assert normalize_whitespace(rendered) == test_case.expected 97 | 98 | def test_default_content_override(self, templates_dir): 99 | test_case = TestComponentCase( 100 | component=TestComponent( 101 | name="test", 102 | content=""" 103 | 112 | """, 113 | ), 114 | template_content=""" 115 | {% bird test %} 116 | {% bird:slot leading-icon %}→{% endbird:slot %} 117 | Submit 118 | {% endbird %} 119 | """, 120 | expected="", 121 | ) 122 | test_case.component.create(templates_dir) 123 | 124 | template = Template(test_case.template_content) 125 | rendered = template.render(Context({})) 126 | 127 | assert normalize_whitespace(rendered) == test_case.expected 128 | 129 | @pytest.mark.parametrize( 130 | "component_content", 131 | [ 132 | "{{ slots.named_slot }}", 133 | "{% bird:slot named_slot %}{% endbird:slot %}", 134 | "{% bird:slot name=named_slot %}{% endbird:slot %}", 135 | ], 136 | ) 137 | def test_named_slot(self, component_content, templates_dir): 138 | test_case = TestComponentCase( 139 | component=TestComponent( 140 | name="test", 141 | content=component_content, 142 | ), 143 | template_content=""" 144 | {% bird test %} 145 | {% bird:slot named_slot %}{% endbird:slot %} 146 | {% endbird %} 147 | """, 148 | template_context={ 149 | "slots": { 150 | "named_slot": "Content", 151 | }, 152 | }, 153 | expected="Content", 154 | ) 155 | test_case.component.create(templates_dir) 156 | 157 | template = Template(test_case.template_content) 158 | rendered = template.render(Context(test_case.template_context)) 159 | 160 | assert normalize_whitespace(rendered) == test_case.expected 161 | 162 | @pytest.mark.parametrize( 163 | "test_case", 164 | [ 165 | TestComponentCase( 166 | description="Outer slot replacement", 167 | component=TestComponent( 168 | name="test", 169 | content=""" 170 | {% bird:slot outer %} 171 | Outer {% bird:slot inner %}Inner{% endbird:slot %} Content 172 | {% endbird:slot %} 173 | """, 174 | ), 175 | template_content=""" 176 | {% bird test %} 177 | {% bird:slot outer %}Replaced Content{% endbird:slot %} 178 | {% endbird %} 179 | """, 180 | expected="Replaced Content", 181 | ), 182 | TestComponentCase( 183 | description="Inner slot replacement", 184 | component=TestComponent( 185 | name="test", 186 | content=""" 187 | {% bird:slot outer %} 188 | Outer {% bird:slot inner %}Inner{% endbird:slot %} Content 189 | {% endbird:slot %} 190 | """, 191 | ), 192 | template_content=""" 193 | {% bird test %} 194 | {% bird:slot inner %}Replaced Content{% endbird:slot %} 195 | {% endbird %} 196 | """, 197 | expected="Outer Replaced Content Content", 198 | ), 199 | TestComponentCase( 200 | description="Both slots replaced", 201 | component=TestComponent( 202 | name="test", 203 | content=""" 204 | {% bird:slot outer %} 205 | Outer {% bird:slot inner %}Inner{% endbird:slot %} Content 206 | {% endbird:slot %} 207 | """, 208 | ), 209 | template_content=""" 210 | {% bird test %} 211 | {% bird:slot outer %} 212 | New {% bird:slot inner %}Nested{% endbird:slot %} Text 213 | {% endbird:slot %} 214 | {% endbird %} 215 | """, 216 | expected="New Nested Text", 217 | ), 218 | ], 219 | ids=lambda x: x.description, 220 | ) 221 | def test_nested_slots(self, test_case, templates_dir): 222 | test_case.component.create(templates_dir) 223 | 224 | template = Template(test_case.template_content) 225 | rendered = template.render(Context(test_case.template_context)) 226 | 227 | assert normalize_whitespace(rendered) == test_case.expected 228 | 229 | @pytest.mark.parametrize( 230 | "test_case", 231 | [ 232 | TestComponentCase( 233 | description="Variable in slot", 234 | component=TestComponent( 235 | name="test", 236 | content="{% bird:slot %}{% endbird:slot %}", 237 | ), 238 | template_content="{% bird test %}{{ message }}{% endbird %}", 239 | template_context={"message": "Hello World"}, 240 | expected="Hello World", 241 | ), 242 | TestComponentCase( 243 | description="Nested variable in slot", 244 | component=TestComponent( 245 | name="test", 246 | content="{% bird:slot %}{% endbird:slot %}", 247 | ), 248 | template_content="{% bird test %}{{ user.name }}{% endbird %}", 249 | template_context={"user": {"name": "John"}}, 250 | expected="John", 251 | ), 252 | TestComponentCase( 253 | description="Template tag in slot", 254 | component=TestComponent( 255 | name="test", 256 | content="{% bird:slot %}{% endbird:slot %}", 257 | ), 258 | template_content="{% bird test %}{% if show %}Show{% endif %}{% endbird %}", 259 | template_context={"show": True}, 260 | expected="Show", 261 | ), 262 | ], 263 | ids=lambda x: x.description, 264 | ) 265 | def test_template_content(self, test_case, templates_dir): 266 | test_case.component.create(templates_dir) 267 | 268 | template = Template(test_case.template_content) 269 | rendered = template.render(Context(test_case.template_context)) 270 | 271 | assert normalize_whitespace(rendered) == test_case.expected 272 | 273 | def test_too_many_args(self): 274 | with pytest.raises(TemplateSyntaxError): 275 | Template("{% bird:slot too many args %}{% endbird:slot %}") 276 | --------------------------------------------------------------------------------