├── py.typed ├── tests ├── __init__.py ├── dj_angles │ ├── __init__.py │ ├── attributes │ │ ├── __init__.py │ │ └── test_attributes.py │ ├── evaluator │ │ ├── __init__.py │ │ └── test_evaluated_function.py │ ├── filters │ │ ├── __init__.py │ │ └── test_dateformat.py │ ├── mappers │ │ ├── __init__.py │ │ ├── angles │ │ │ ├── __init__.py │ │ │ ├── test_map_call.py │ │ │ ├── test_map_model.py │ │ │ └── test_map_form.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── test_map_extends.py │ │ │ ├── test_map_image.py │ │ │ ├── test_map_css.py │ │ │ └── test_map_block.py │ │ ├── mapper │ │ │ ├── __init__.py │ │ │ └── test_get_tag_map.py │ │ ├── thirdparty │ │ │ ├── __init__.py │ │ │ ├── test_map_partialdef.py │ │ │ └── test_map_bird.py │ │ └── include │ │ │ ├── test_get_include_template_file.py │ │ │ └── test_map_include.py │ ├── middleware │ │ ├── __init__.py │ │ ├── test_request_ajax.py │ │ └── test_request_method.py │ ├── strings │ │ ├── __init__.py │ │ └── test_replace_newlines.py │ ├── tokenizer │ │ ├── __init__.py │ │ └── test_yield_tokens.py │ ├── caseconverters │ │ ├── __init__.py │ │ └── test_kebabify.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── call │ │ │ ├── __init__.py │ │ │ └── test_do_call.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── test_do_model.py │ │ │ └── test_render.py │ │ ├── test_dj_angles.py │ │ └── template │ │ │ └── test_render.py │ ├── tags │ │ ├── __init__.py │ │ └── test_tag.py │ ├── regex_replacer │ │ ├── test_get_attribute_replacements.py │ │ └── test_django_tag_replacer.py │ └── test_template_loader.py └── templates │ ├── _underscore.html │ ├── components │ └── _underscore.html │ └── slot.html ├── example ├── __init__.py ├── www │ ├── __init__.py │ ├── templates │ │ ├── _partial.html │ │ └── www │ │ │ ├── components │ │ │ ├── include.html │ │ │ ├── include-slot.html │ │ │ └── include-shadow.html │ │ │ ├── index.html │ │ │ ├── bird.html │ │ │ ├── base.html │ │ │ └── include.html │ ├── urls.py │ └── views.py ├── book │ ├── __init__.py │ ├── apps.py │ └── models.py ├── project │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── templates │ └── bird │ │ └── button.html └── manage.py ├── src └── dj_angles │ ├── templatetags │ ├── __init__.py │ ├── dj_angles.py │ ├── model.py │ └── template.py │ ├── caseconverter │ ├── __init__.py │ ├── kebab.py │ ├── LICENSE │ ├── boundaries.py │ ├── README.md │ └── caseconverter.py │ ├── __init__.py │ ├── templates │ └── dj_angles │ │ └── scripts.html │ ├── modules.py │ ├── regex_replacer │ ├── objects.py │ ├── __init__.py │ ├── django_tag_replacer.py │ └── tag_replacer.py │ ├── mappers │ ├── __init__.py │ ├── thirdparty.py │ ├── mapper.py │ ├── include.py │ ├── django.py │ └── angles.py │ ├── strings.py │ ├── middleware.py │ ├── settings.py │ ├── exceptions.py │ ├── template_loader.py │ ├── tokenizer.py │ ├── templates.py │ ├── htmls.py │ ├── static │ └── dj_angles │ │ └── js │ │ └── ajax-form.js │ └── evaluator.py ├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── checks.yml ├── .gitignore ├── justfile ├── DEVELOPING.md ├── .all-contributorsrc ├── docs └── source │ ├── template-tags │ ├── model.md │ └── template.md │ ├── middlewares │ ├── request-method.md │ └── request-ajax.md │ ├── inline-expressions.md │ ├── tag-attributes.md │ ├── conf.py │ ├── settings.md │ ├── examples.md │ ├── mappers.md │ ├── installation.md │ ├── integrations │ ├── django-template-partials.md │ └── django-bird.md │ ├── custom-elements │ └── ajax-form.md │ ├── changelog.md │ ├── index.md │ └── tag-elements.md ├── .readthedocs.yml ├── LICENSE ├── conftest.py ├── pyproject.toml └── CHANGELOG.md /py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/www/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/book/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dj_angles/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/attributes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/evaluator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/filters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/strings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/tokenizer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/caseconverters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/angles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/mapper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/_underscore.html: -------------------------------------------------------------------------------- 1 | _underscore -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: adamghill 2 | 3 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/thirdparty/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/call/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/components/_underscore.html: -------------------------------------------------------------------------------- 1 | components/_underscore -------------------------------------------------------------------------------- /example/www/templates/_partial.html: -------------------------------------------------------------------------------- 1 |
2 | This is a partial. 3 |
-------------------------------------------------------------------------------- /example/templates/bird/button.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/slot.html: -------------------------------------------------------------------------------- 1 |
2 | slot1 3 | slot2 4 |
-------------------------------------------------------------------------------- /example/www/templates/www/components/include.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | This is a regular include. 4 |

5 |
-------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .venv/ 3 | .pytest_cache/ 4 | 5 | .DS_Store 6 | TODO 7 | .coverage 8 | example/db.sqlite3 9 | docs/build -------------------------------------------------------------------------------- /example/book/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Config(AppConfig): 5 | name = "example.book" 6 | -------------------------------------------------------------------------------- /src/dj_angles/caseconverter/__init__.py: -------------------------------------------------------------------------------- 1 | from dj_angles.caseconverter.kebab import kebabify 2 | 3 | __all__ = [ 4 | "kebabify", 5 | ] 6 | -------------------------------------------------------------------------------- /example/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from www import urls as www_urls 3 | 4 | urlpatterns = [ 5 | path("", include(www_urls)), 6 | ] 7 | -------------------------------------------------------------------------------- /src/dj_angles/__init__.py: -------------------------------------------------------------------------------- 1 | from dj_angles.tags import Tag 2 | from dj_angles.template_loader import Loader 3 | 4 | __all__ = [ 5 | "Loader", 6 | "Tag", 7 | ] 8 | -------------------------------------------------------------------------------- /example/www/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from www import views 3 | 4 | app_name = "www" 5 | 6 | urlpatterns = [ 7 | re_path(r".*", views.view), 8 | ] 9 | -------------------------------------------------------------------------------- /src/dj_angles/templates/dj_angles/scripts.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | {% if ajax_form %} 4 | 5 | {% endif %} -------------------------------------------------------------------------------- /example/www/templates/www/components/include-slot.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | This is a slot include. 4 |

5 | 6 | This is the default slot. 7 |
-------------------------------------------------------------------------------- /src/dj_angles/modules.py: -------------------------------------------------------------------------------- 1 | from importlib.util import find_spec 2 | 3 | 4 | def is_module_available(module_name): 5 | """Helper method to check if a module is available.""" 6 | 7 | return find_spec(module_name) is not None 8 | -------------------------------------------------------------------------------- /example/www/templates/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | This is an example Django site that shows how dj-angles works. 6 |

7 |
-------------------------------------------------------------------------------- /example/www/templates/www/components/include-shadow.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | This is a shadow include. 4 |

5 |
6 | 7 | -------------------------------------------------------------------------------- /example/www/views.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.shortcuts import render 3 | 4 | 5 | def view(request): 6 | path = request.path 7 | template_name = "index" 8 | 9 | if len(path) > 1 and path.startswith("/"): 10 | template_name = path[1:] 11 | 12 | if template_name == "favicon.ico": 13 | raise Http404() 14 | 15 | return render(request, f"www/{template_name}.html") 16 | -------------------------------------------------------------------------------- /example/www/templates/www/bird.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Click me! 6 | 7 | 8 | 9 | 10 | {% bird button class="btn" %} 11 | Click me! 12 | {% endbird %} 13 | 14 | {% bird button class="btn btn-primary" id="submit-btn" disabled %} 15 | Submit 16 | {% endbird %} 17 | -------------------------------------------------------------------------------- /example/project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/dj_angles/regex_replacer/objects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from dj_angles.tags import Tag 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @dataclass 10 | class Replacement: 11 | original: str 12 | replacement: str 13 | tag: Tag 14 | keep_endif: bool = False 15 | tag_start_idx: int = -1 16 | 17 | def __repr__(self): 18 | return f"Replacement(original={self.original!r}, replacement={self.replacement!r})" 19 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | import? 'adamghill.justfile' 2 | import? '../dotfiles/just/justfile' 3 | 4 | src := "src/dj_angles" 5 | 6 | # List commands 7 | _default: 8 | just --list --unsorted --justfile {{ justfile() }} --list-heading $'Available commands:\n' 9 | 10 | # Grab default `adamghill.justfile` from GitHub 11 | fetch: 12 | curl https://raw.githubusercontent.com/adamghill/dotfiles/master/just/justfile > adamghill.justfile 13 | 14 | serve: 15 | uv run python3 example/manage.py runserver 0:8789 16 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/mapper/test_get_tag_map.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.mapper import get_tag_map 2 | 3 | 4 | def test_default(): 5 | expected = "default_mapper" 6 | actual = get_tag_map() 7 | 8 | assert len(actual) == 25 9 | assert actual[None].__name__ == expected 10 | 11 | 12 | def test_none_default_mapper(settings): 13 | settings.ANGLES["default_mapper"] = None 14 | 15 | actual = get_tag_map() 16 | 17 | assert len(actual) == 24 18 | assert actual.get(None) is None 19 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | 1. Install `uv` 4 | 1. `uv sync --all-extras` 5 | 6 | # Publishing 7 | 8 | 1. `just dev` 9 | 1. Update version in `pyproject.toml` 10 | 1. Update `CHANGELOG.md` 11 | 1. `just docs-build` 12 | 1. Commit and tag with a version 13 | 1. `git push --tags origin main` 14 | 1. Go to https://github.com/adamghill/dj-angles/releases and create a new release with the same version 15 | 1. This will kick off the https://github.com/adamghill/dj-angles/actions/workflows/publish.yml GitHub Action 16 | -------------------------------------------------------------------------------- /example/book/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Book(models.Model): 5 | TYPES = ((1, "Hardcover"), (2, "Softcover")) 6 | title = models.CharField(max_length=255) 7 | date_published = models.DateField(blank=True, null=True) 8 | type = models.IntegerField(choices=TYPES, default=1) 9 | 10 | def __str__(self): 11 | return self.title 12 | 13 | 14 | class Author(models.Model): 15 | name = models.CharField(max_length=1024) 16 | books = models.ManyToManyField(Book) 17 | -------------------------------------------------------------------------------- /tests/dj_angles/filters/test_dateformat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from dj_angles.templatetags.dj_angles import dateformat 4 | 5 | 6 | def test_p(): 7 | expected = "PM" 8 | 9 | dt = datetime(2025, 3, 15, 14, 3, 6, tzinfo=timezone.utc) 10 | actual = dateformat(dt, "%p") 11 | 12 | assert actual == expected 13 | 14 | 15 | def test_d(): 16 | expected = "15" 17 | 18 | dt = datetime(2025, 3, 15, 14, 3, 6, tzinfo=timezone.utc) 19 | actual = dateformat(dt, "%d") 20 | 21 | assert actual == expected 22 | -------------------------------------------------------------------------------- /tests/dj_angles/middleware/test_request_ajax.py: -------------------------------------------------------------------------------- 1 | from dj_angles.middleware import RequestAJAXMiddleware 2 | 3 | 4 | def test_not_ajax(rf): 5 | middleware = RequestAJAXMiddleware(lambda _: None) 6 | 7 | request = rf.get("/") 8 | middleware.__call__(request) 9 | 10 | assert not request.is_ajax 11 | 12 | 13 | def test_ajax(rf): 14 | middleware = RequestAJAXMiddleware(lambda _: None) 15 | 16 | request = rf.get("/", HTTP_X_REQUESTED_WITH="XMLHttpRequest") 17 | middleware.__call__(request) 18 | 19 | assert request.is_ajax 20 | -------------------------------------------------------------------------------- /tests/dj_angles/strings/test_replace_newlines.py: -------------------------------------------------------------------------------- 1 | from dj_angles.strings import replace_newlines 2 | 3 | 4 | def test_replace_newlines(): 5 | assert replace_newlines("\r\n") == "" 6 | assert replace_newlines("\n") == "" 7 | assert replace_newlines("\r") == "" 8 | assert replace_newlines("\r\n\r\n\n\n") == "" 9 | 10 | 11 | def test_replace_newlines_with_string(): 12 | assert replace_newlines("\r\n", "c") == "c" 13 | assert replace_newlines("\n", "c") == "c" 14 | assert replace_newlines("\r", "c") == "c" 15 | assert replace_newlines("\r\n\r\n\n\n", "c") == "cccc" 16 | -------------------------------------------------------------------------------- /tests/dj_angles/tags/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from dj_angles.mappers.mapper import get_tag_map 4 | from dj_angles.settings import get_tag_regex 5 | from dj_angles.tags import Tag 6 | 7 | 8 | def create_tag(html): 9 | tag_regex = get_tag_regex() 10 | match = re.match(tag_regex, html) 11 | 12 | tag_html = html[match.start() : match.end()] 13 | tag_name = match.group("tag_name").strip() 14 | template_tag_args = match.group("template_tag_args").strip() 15 | 16 | tag_map = get_tag_map() 17 | 18 | return Tag( 19 | tag_map, 20 | html=tag_html, 21 | tag_name=tag_name, 22 | template_tag_args=template_tag_args, 23 | ) 24 | -------------------------------------------------------------------------------- /src/dj_angles/mappers/__init__.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.angles import default_mapper, map_angles_include, map_call, map_model 2 | from dj_angles.mappers.django import map_autoescape, map_block, map_css, map_endblock, map_extends, map_image 3 | from dj_angles.mappers.include import map_include 4 | from dj_angles.mappers.thirdparty import map_bird, map_partial 5 | 6 | __all__ = [ 7 | "default_mapper", 8 | "map_angles_include", 9 | "map_autoescape", 10 | "map_bird", 11 | "map_block", 12 | "map_call", 13 | "map_css", 14 | "map_endblock", 15 | "map_extends", 16 | "map_image", 17 | "map_include", 18 | "map_model", 19 | "map_partial", 20 | ] 21 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "nanuxbe", 12 | "name": "Emmanuelle Delescolle", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/1215070?v=4", 14 | "profile": "http://www.levit.be", 15 | "contributions": [ 16 | "code", 17 | "test", 18 | "doc" 19 | ] 20 | } 21 | ], 22 | "contributorsPerLine": 7, 23 | "skipCi": true, 24 | "repoType": "github", 25 | "repoHost": "https://github.com", 26 | "projectName": "dj-angles", 27 | "projectOwner": "adamghill" 28 | } 29 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 11 | 12 | try: 13 | from django.core.management import execute_from_command_line # noqa: PLC0415 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /src/dj_angles/templatetags/dj_angles.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time 2 | 3 | from django import template 4 | 5 | from dj_angles.templatetags.call import do_call 6 | from dj_angles.templatetags.model import do_model 7 | from dj_angles.templatetags.template import do_template 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.filter(name="dateformat") 13 | def dateformat(value: datetime | date | time, date_format: str): 14 | return value.strftime(date_format) 15 | 16 | 17 | @register.inclusion_tag("dj_angles/scripts.html") 18 | def dj_angles_scripts(*, ajax_form: bool = True): 19 | return {"ajax_form": ajax_form} 20 | 21 | 22 | # Register custom template tags 23 | register.tag("call", do_call) 24 | register.tag("model", do_model) 25 | register.tag("template", do_template) 26 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/angles/test_map_call.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.angles import map_call 2 | from tests.dj_angles.tags import create_tag 3 | 4 | 5 | def test_as(): 6 | expected = '{% call slugify("Hello Goodbye") as slug %}' 7 | 8 | tag = create_tag("") 9 | actual = map_call(tag=tag) 10 | 11 | assert actual == expected 12 | 13 | 14 | def test_no_as(): 15 | expected = '{% call slugify("Hello Goodbye") %}' 16 | 17 | tag = create_tag("") 18 | actual = map_call(tag=tag) 19 | 20 | assert actual == expected 21 | 22 | 23 | def test_end_tag(): 24 | expected = "" 25 | 26 | tag = create_tag("") 27 | actual = map_call(tag=tag) 28 | 29 | assert actual == expected 30 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/angles/test_map_model.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.angles import map_model 2 | from tests.dj_angles.tags import create_tag 3 | 4 | 5 | def test_as(): 6 | expected = "{% model Book.objects.filter(id=1) as book %}" 7 | 8 | tag = create_tag("") 9 | actual = map_model(tag=tag) 10 | 11 | assert actual == expected 12 | 13 | 14 | def test_no_as(): 15 | expected = "{% model Book.objects.filter(id=1) %}" 16 | 17 | tag = create_tag("") 18 | actual = map_model(tag=tag) 19 | 20 | assert actual == expected 21 | 22 | 23 | def test_end_tag(): 24 | expected = "" 25 | 26 | tag = create_tag("") 27 | actual = map_model(tag=tag) 28 | 29 | assert actual == expected 30 | -------------------------------------------------------------------------------- /docs/source/template-tags/model.md: -------------------------------------------------------------------------------- 1 | # model 2 | 3 | The `model` template tag is a specialized version of the `call` template tag that automatically makes all database models available to be queried from the template without passing the model class into the context. 4 | 5 | ```{note} 6 | Make sure to [install `dj_angles`](../installation.md#template-tags) and include `{% load dj_angles %}` in your template if `"dj_angles.templatetags.dj_angles"` is not added to template built-ins. 7 | ``` 8 | 9 | ```html 10 | 11 | {% model Book.objects.filter(published__gte='2020-01-01') as books %} 12 | 13 | {% for book in books %} 14 |
{{ book }}
15 | {% endfor %} 16 | ``` 17 | 18 | ```python 19 | # views.py 20 | from django.shortcuts import render 21 | 22 | def index(request): 23 | return render(request, 'index.html', {}) 24 | ``` 25 | -------------------------------------------------------------------------------- /tests/dj_angles/regex_replacer/test_get_attribute_replacements.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import pytest 4 | 5 | from dj_angles.regex_replacer import get_attribute_replacements 6 | 7 | # Structure to store parameterize data 8 | Params = namedtuple( 9 | "Params", 10 | ("original", "replacement"), 11 | ) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | Params._fields, 16 | ( 17 | Params( 18 | original="", 19 | replacement="{% if True %}{% endif %}", 20 | ), 21 | ), 22 | ) 23 | def test_if(original, replacement): 24 | actual = get_attribute_replacements(original) 25 | 26 | for tag_replacement in actual: 27 | assert tag_replacement.original == original 28 | assert tag_replacement.replacement == replacement 29 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/model/test_do_model.py: -------------------------------------------------------------------------------- 1 | from django.template.base import Token, TokenType 2 | 3 | from dj_angles.templatetags.model import do_model 4 | 5 | 6 | def test_model(): 7 | token = Token(TokenType.BLOCK, contents="model Book.objects.filter(id=1).first() as books") 8 | actual = do_model(None, token) 9 | 10 | assert actual.parsed_function.portions[0].name == "__dj_angles_models" 11 | assert actual.parsed_function.portions[1].name == "Book" 12 | assert actual.parsed_function.portions[2].name == "objects" 13 | assert actual.parsed_function.portions[3].name == "filter" 14 | assert actual.parsed_function.portions[3].args == [] 15 | assert actual.parsed_function.portions[3].kwargs == {"id": 1} 16 | assert actual.parsed_function.portions[4].name == "first" 17 | assert actual.context_variable_name == "books" 18 | -------------------------------------------------------------------------------- /src/dj_angles/caseconverter/kebab.py: -------------------------------------------------------------------------------- 1 | from dj_angles.caseconverter.boundaries import OnDelimeterLowercaseNext, OnUpperPrecededByLowerAppendLower 2 | from dj_angles.caseconverter.caseconverter import CaseConverter 3 | 4 | 5 | class Kebab(CaseConverter): 6 | JOIN_CHAR = "-" 7 | 8 | def define_boundaries(self): 9 | self.add_boundary_handler(OnDelimeterLowercaseNext(self.delimiters(), self.JOIN_CHAR)) 10 | self.add_boundary_handler(OnUpperPrecededByLowerAppendLower(self.JOIN_CHAR)) 11 | 12 | def prepare_string(self, s): 13 | if s.isupper(): 14 | return s.lower() 15 | 16 | return s 17 | 18 | def mutate(self, c): 19 | return c.lower() 20 | 21 | 22 | def kebabify(s, **kwargs): 23 | """Convert a string to kebab case 24 | 25 | Example 26 | 27 | Hello World => hello-world 28 | 29 | """ 30 | return Kebab(s, **kwargs).convert() 31 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | version: 2 5 | 6 | sphinx: 7 | configuration: docs/source/conf.py 8 | fail_on_warning: true 9 | builder: dirhtml 10 | 11 | build: 12 | os: ubuntu-lts-latest 13 | tools: 14 | python: latest 15 | jobs: 16 | pre_create_environment: 17 | - asdf plugin add uv 18 | - asdf install uv latest 19 | - asdf global uv latest 20 | create_environment: 21 | - uv venv $READTHEDOCS_VIRTUALENV_PATH 22 | install: 23 | # Use a cache dir in the same mount to halve the install time 24 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH uv sync --cache-dir $READTHEDOCS_VIRTUALENV_PATH/../../uv_cache --group docs 25 | build: 26 | html: 27 | - uv run sphinx-build -T -b dirhtml docs/source $READTHEDOCS_OUTPUT/html 28 | -------------------------------------------------------------------------------- /src/dj_angles/strings.py: -------------------------------------------------------------------------------- 1 | def dequotify(s: str) -> str: 2 | """Removes single or double quotes from a string. 3 | 4 | Args: 5 | param s: The string to remove quotes from. 6 | 7 | Returns: 8 | A new string without the quotes or the original if there were no quotes. 9 | """ 10 | 11 | if (s.startswith("'") and s.endswith("'")) or (s.startswith('"') and s.endswith('"')): 12 | return s[1:-1] 13 | 14 | return s 15 | 16 | 17 | def replace_newlines(s: str, replacement: str = "") -> str: 18 | """Replaces newlines with the given replacement string. 19 | 20 | Args: 21 | param s: The string to replace newlines in. 22 | param replacement: The string to replace the newlines with. 23 | 24 | Returns: 25 | A new string with the newlines replaced. 26 | """ 27 | 28 | return s.replace("\r\n", replacement).replace("\n", replacement).replace("\r", replacement) 29 | -------------------------------------------------------------------------------- /docs/source/middlewares/request-method.md: -------------------------------------------------------------------------------- 1 | # RequestMethodMiddleware 2 | 3 | Adds the request's method as boolean properties to the `request` object. 4 | 5 | ```{note} 6 | Make sure to [install `dj_angles` middleware](../installation.md#middleware) to access this functionality. 7 | ``` 8 | 9 | ## Example 10 | 11 | ```python 12 | # views.py 13 | from django.shortcuts import render 14 | from book.models import Book 15 | 16 | def book(request, book_id): 17 | book = Book.objects.filter(id=book_id).first() 18 | 19 | if request.is_post: 20 | book.name = request.POST.get('name') 21 | book.save() 22 | 23 | return redirect('book', id=book.id) 24 | 25 | return render(request, 'book.html', {'book': book}) 26 | ``` 27 | 28 | ## Properties 29 | 30 | - `request.is_post` 31 | - `request.is_get` 32 | - `request.is_patch` 33 | - `request.is_head` 34 | - `request.is_put` 35 | - `request.is_delete` 36 | - `request.is_trace` -------------------------------------------------------------------------------- /docs/source/inline-expressions.md: -------------------------------------------------------------------------------- 1 | # Inline Expressions 2 | 3 | The `dj-angles` approach is shown first and then the equivalent Django Template Language is second. 4 | 5 | ## `or` 6 | 7 | Similar to the [`default` filter](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#default), but feels a little more Pythonic. 8 | 9 | ```html 10 | {{ request.user.username or request.user.email }} 11 | ``` 12 | 13 | ```html 14 | {% if request.user.username %}{{ request.user.username }}{% else %}{{ request.user.email }}{% endif %} 15 | ``` 16 | 17 | ## `if` 18 | 19 | Python ternary operator where the first part is a conditional, the part after the " if " is the true value, and the part after the " else " is the false value. 20 | 21 | ```html 22 | {{ request.user.username if request.user.is_authenticated else 'Unknown' }} 23 | ``` 24 | 25 | ```html 26 | {% if request.user.is_authenticated %}{{ request.user.username }}{% else %}Unknown{% endif %} 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/source/middlewares/request-ajax.md: -------------------------------------------------------------------------------- 1 | # RequestAJAXMiddleware 2 | 3 | Adds whether the request is AJAX as a boolean property to the `request` object. Useful to check when rendering a view's HTML via AJAX, i.e. using the [`form` tag](../tag-elements.md#form) or [HTMX](https://htmx.org/). 4 | 5 | ```{note} 6 | Make sure to [install `dj_angles` middleware](../installation.md#middleware) to access this functionality. 7 | ``` 8 | 9 | ## Example 10 | 11 | ```python 12 | # views.py 13 | from django.shortcuts import render 14 | from book.models import Book 15 | 16 | def book(request, book_id): 17 | book = Book.objects.filter(id=book_id).first() 18 | 19 | if request.is_post: 20 | book.title = request.POST.get('title') 21 | book.save() 22 | 23 | if not request.is_ajax: 24 | return redirect('book', id=book.id) 25 | 26 | return render(request, 'book.html', {'book': book}) 27 | ``` 28 | 29 | ## Properties 30 | 31 | - `request.is_ajax` 32 | -------------------------------------------------------------------------------- /tests/dj_angles/test_template_loader.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | from django.template import Engine 4 | 5 | from dj_angles.template_loader import Loader 6 | 7 | 8 | def test_get_contents_returns_original_when_no_angles(tmp_path): 9 | html = "
hello
\n" 10 | template_path = tmp_path / "plain.html" 11 | template_path.write_text(html, encoding="utf-8") 12 | 13 | engine = Engine() 14 | loader = Loader(engine=engine) 15 | origin = SimpleNamespace(name=str(template_path)) 16 | 17 | actual = loader.get_contents(origin) 18 | 19 | assert actual == html 20 | 21 | 22 | def test_get_contents_converts_when_angles_present(tmp_path): 23 | expected = "{% debug %}" 24 | 25 | template_path = tmp_path / "has_angles.html" 26 | html = "" 27 | template_path.write_text(html, encoding="utf-8") 28 | 29 | engine = Engine() 30 | loader = Loader(engine=engine) 31 | origin = SimpleNamespace(name=str(template_path)) 32 | 33 | actual = loader.get_contents(origin) 34 | 35 | assert expected == actual 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: ["published"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | pypi-publish: 13 | name: Upload release to PyPI 14 | runs-on: ubuntu-latest 15 | 16 | environment: 17 | name: release 18 | url: https://pypi.org/project/dj-angles/ 19 | 20 | permissions: 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v3 28 | with: 29 | enable-cache: true 30 | 31 | - name: Set up Python 32 | run: uv python install 3.12 33 | 34 | - name: Build 35 | run: uv build 36 | 37 | # - name: Publish package distributions to Test PyPI 38 | # uses: pypa/gh-action-pypi-publish@release/v1 39 | # with: 40 | # repository-url: https://test.pypi.org/legacy/ 41 | # skip-existing: true 42 | 43 | - name: Publish package distributions to PyPI 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Hill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/dj_angles/caseconverter/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chris Doherty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/tag-attributes.md: -------------------------------------------------------------------------------- 1 | # Attributes 2 | 3 | The `dj-angles` approach is shown first and then the equivalent Django Template Language is second. 4 | 5 | ## [`if`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#if) 6 | 7 | ```html 8 |
...
9 | ``` 10 | 11 | ```html 12 | {% if True %}
...
{% endif %} 13 | ``` 14 | 15 | ## [`elif`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#if) 16 | 17 | ```html 18 |
19 | if 20 |
21 |
22 | elif 23 |
24 | ``` 25 | 26 | ```html 27 | {% if some_list.0 %} 28 |
29 | if 30 |
31 | {% elif some_list.1 %} 32 |
33 | elif 34 |
35 | {% endif %} 36 | ``` 37 | 38 | ## [`else`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#if) 39 | 40 | ```html 41 |
42 | if 43 |
44 |
45 | elif 46 |
47 |
48 | else 49 |
50 | ``` 51 | 52 | ```html 53 | {% if some_variable == 1 %} 54 |
55 | if 56 |
57 | {% elif some_variable == 2 %} 58 |
59 | elif 60 |
61 | {% else %} 62 |
63 | else 64 |
65 | {% endif %} 66 | ``` -------------------------------------------------------------------------------- /src/dj_angles/middleware.py: -------------------------------------------------------------------------------- 1 | class RequestMethodMiddleware: 2 | """Adds the request method as boolean properties to the request object.""" 3 | 4 | def __init__(self, get_response): 5 | self.get_response = get_response 6 | 7 | def __call__(self, request): 8 | request.is_post = request.method == "POST" 9 | request.is_get = request.method == "GET" 10 | request.is_patch = request.method == "PATCH" 11 | request.is_head = request.method == "HEAD" 12 | request.is_put = request.method == "PUT" 13 | request.is_delete = request.method == "DELETE" 14 | request.is_connect = request.method == "CONNECT" 15 | request.is_trace = request.method == "TRACE" 16 | 17 | response = self.get_response(request) 18 | 19 | return response 20 | 21 | 22 | class RequestAJAXMiddleware: 23 | """Adds whether the request is AJAX as a boolean property to the request object.""" 24 | 25 | def __init__(self, get_response): 26 | self.get_response = get_response 27 | 28 | def __call__(self, request): 29 | request.is_ajax = request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" 30 | 31 | response = self.get_response(request) 32 | 33 | return response 34 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/django/test_map_extends.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dj_angles.exceptions import MissingAttributeError 4 | from dj_angles.mappers.django import map_extends 5 | from tests.dj_angles.tags import create_tag 6 | 7 | 8 | def test_string(): 9 | expected = "{% extends 'base.html' %}" 10 | 11 | html = "" 12 | tag = create_tag(html) 13 | 14 | actual = map_extends(tag=tag) 15 | 16 | assert actual == expected 17 | 18 | 19 | def test_parent_attribute(): 20 | expected = "{% extends 'base.html' %}" 21 | 22 | html = "" 23 | tag = create_tag(html) 24 | 25 | actual = map_extends(tag=tag) 26 | 27 | assert actual == expected 28 | 29 | 30 | def test_no_extension(): 31 | expected = "{% extends 'base.html' %}" 32 | 33 | html = "" 34 | tag = create_tag(html) 35 | 36 | actual = map_extends(tag=tag) 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_missing_parent_throws_exception(): 42 | html = "" 43 | tag = create_tag(html) 44 | 45 | with pytest.raises(MissingAttributeError) as e: 46 | map_extends(tag=tag) 47 | 48 | assert e.value.name == "parent" 49 | -------------------------------------------------------------------------------- /example/www/templates/www/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | dj-angles examples 10 | 11 | 12 | 13 | 14 |
15 |

16 | dj-angles </> 17 |

18 |

19 | https://github.com/adamghill/dj-angles 20 |

21 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/dj_angles/mappers/thirdparty.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from dj_angles.exceptions import MissingAttributeError 5 | from dj_angles.mappers.django import map_block 6 | from dj_angles.strings import dequotify 7 | 8 | if TYPE_CHECKING: 9 | from dj_angles.tags import Tag 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def map_bird(tag: "Tag") -> str: 16 | if tag.is_end: 17 | return "{% endbird %}" 18 | 19 | template_file = tag.tag_name 20 | 21 | if template_file == "bird": 22 | try: 23 | template_file = tag.get_attribute_value_or_first_key("template") 24 | except MissingAttributeError: 25 | pass 26 | 27 | django_template_tag = f"{{% bird {template_file}" 28 | 29 | if tag.attributes: 30 | django_template_tag = f"{django_template_tag} {tag.attributes}" 31 | 32 | if tag.is_self_closing: 33 | django_template_tag = f"{django_template_tag} /" 34 | 35 | return f"{django_template_tag} %}}" 36 | 37 | 38 | def map_partial(tag: "Tag") -> str: 39 | if tag.is_self_closing: 40 | name = tag.get_attribute_value_or_first_key("name") 41 | name = dequotify(name) 42 | 43 | return f"{{% partial {name} %}}" 44 | 45 | return map_block(tag=tag, tag_name="partialdef") 46 | -------------------------------------------------------------------------------- /src/dj_angles/settings.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import lru_cache 3 | from typing import Any 4 | 5 | from django.conf import settings 6 | 7 | 8 | def get_setting(setting_name: str, default=None) -> Any: 9 | """Get a `dj-angles` setting from the `ANGLES` setting dictionary. 10 | 11 | Args: 12 | param setting_name: The name of the setting. 13 | param default: The value that should be returned if the setting is missing. 14 | """ 15 | 16 | if not hasattr(settings, "ANGLES"): 17 | settings.ANGLES = {} 18 | 19 | if setting_name in settings.ANGLES: 20 | return settings.ANGLES[setting_name] 21 | 22 | return default 23 | 24 | 25 | def get_tag_regex(): 26 | """Gets a compiled regex based on the `initial_tag_regex` setting or default of r'(dj-)'.""" 27 | 28 | initial_tag_regex = get_setting("initial_tag_regex", default=r"(dj-)") 29 | 30 | if initial_tag_regex is None: 31 | initial_tag_regex = "" 32 | 33 | tag_regex = rf"[^\s>]+))\s*(?P.*?)\s*/?>" 34 | 35 | @lru_cache(maxsize=32) 36 | def _compile_regex(_tag_regex): 37 | """Silly internal function to cache the compiled regex.""" 38 | 39 | return re.compile(_tag_regex, re.DOTALL) 40 | 41 | return _compile_regex(tag_regex) 42 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/django/test_map_image.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dj_angles.exceptions import MissingAttributeError 4 | from dj_angles.mappers.django import map_image 5 | from tests.dj_angles.tags import create_tag 6 | 7 | 8 | def test_string(): 9 | expected = "" 10 | 11 | html = "" 12 | tag = create_tag(html) 13 | 14 | actual = map_image(tag=tag) 15 | 16 | assert actual == expected 17 | 18 | 19 | def test_src_attribute(): 20 | expected = "" 21 | 22 | html = "" 23 | tag = create_tag(html) 24 | 25 | actual = map_image(tag=tag) 26 | 27 | assert actual == expected 28 | 29 | 30 | def test_src_second_attribute(): 31 | expected = "" 32 | 33 | html = "" 34 | tag = create_tag(html) 35 | 36 | actual = map_image(tag=tag) 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_missing_src_throws_exception(): 42 | html = "" 43 | tag = create_tag(html) 44 | 45 | with pytest.raises(MissingAttributeError) as e: 46 | map_image(tag=tag) 47 | 48 | assert e.value.name == "src" 49 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/model/test_render.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from django.template.base import Token, TokenType 5 | from django.template.context import RenderContext 6 | from example.book.models import Book 7 | 8 | from dj_angles.templatetags.model import do_model 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_model(): 13 | Book.objects.create(id=1, title="Tom Sawyer") 14 | 15 | token = Token(TokenType.BLOCK, contents="model Book.objects.filter(id=1).first() as book") 16 | node = do_model(None, token) 17 | 18 | context = RenderContext({"Book": Book}) 19 | node.render(context) 20 | 21 | assert "book" in context 22 | assert isinstance(context["book"], Book) 23 | assert context["book"].title == "Tom Sawyer" 24 | 25 | 26 | @pytest.mark.django_db 27 | @patch("dj_angles.templatetags.model.get_models") 28 | def test_model_verify_models_are_cached(get_models): 29 | Book.objects.create(id=1, title="Tom Sawyer") 30 | 31 | token = Token(TokenType.BLOCK, contents="model Book.objects.filter(id=1).first() as book") 32 | node = do_model(None, token) 33 | 34 | context = RenderContext({"Book": Book}) 35 | 36 | assert get_models.call_count == 0 37 | 38 | node.render(context) 39 | 40 | get_models.assert_called_once() 41 | 42 | node.render(context) 43 | get_models.assert_called_once() 44 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | pytest: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: 13 | - "3.10" 14 | - "3.11" 15 | - "3.12" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v3 22 | with: 23 | enable-cache: true 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install the package 31 | run: uv sync --all-extras --dev 32 | 33 | - name: Run pytest 34 | run: uv run pytest 35 | 36 | ruff: 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: astral-sh/ruff-action@v1 42 | 43 | mypy: 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Install uv 50 | uses: astral-sh/setup-uv@v3 51 | with: 52 | enable-cache: true 53 | 54 | - name: Set up Python 55 | uses: actions/setup-python@v5 56 | 57 | - name: Install the package 58 | run: uv sync --all-extras --dev 59 | 60 | - name: Run mypy 61 | run: uv run mypy src/dj_angles 62 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/django/test_map_css.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dj_angles.exceptions import MissingAttributeError 4 | from dj_angles.mappers.django import map_css 5 | from tests.dj_angles.tags import create_tag 6 | 7 | 8 | def test_string(): 9 | expected = '' 10 | 11 | html = "" 12 | tag = create_tag(html) 13 | 14 | actual = map_css(tag=tag) 15 | 16 | assert actual == expected 17 | 18 | 19 | def test_href_attribute(): 20 | expected = '' 21 | 22 | html = "" 23 | tag = create_tag(html) 24 | 25 | actual = map_css(tag=tag) 26 | 27 | assert actual == expected 28 | 29 | 30 | def test_href_second_attribute(): 31 | expected = "" 32 | 33 | html = "" 34 | tag = create_tag(html) 35 | 36 | actual = map_css(tag=tag) 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_missing_href_throws_exception(): 42 | html = "" 43 | tag = create_tag(html) 44 | 45 | with pytest.raises(MissingAttributeError) as e: 46 | map_css(tag=tag) 47 | 48 | assert e.value.name == "href" 49 | -------------------------------------------------------------------------------- /tests/dj_angles/evaluator/test_evaluated_function.py: -------------------------------------------------------------------------------- 1 | from dj_angles.evaluator import EvaluatedFunction, eval_function 2 | from dj_angles.templatetags.call import TemplateVariable 3 | 4 | 5 | def test_no_args(): 6 | expected = EvaluatedFunction("set_name", [], {}) 7 | actual = eval_function("set_name()") 8 | 9 | assert expected == actual 10 | 11 | 12 | def test_no_parens(): 13 | expected = EvaluatedFunction("set_name", [], {}) 14 | actual = eval_function("set_name") 15 | 16 | assert expected == actual 17 | 18 | 19 | def test_str_arg(): 20 | expected = EvaluatedFunction("set_name", ["Bob"], {}) 21 | actual = eval_function("set_name('Bob')") 22 | 23 | assert expected == actual 24 | 25 | 26 | def test_template_variable(): 27 | actual = eval_function("set_name(request)") 28 | 29 | assert actual.function_name == "set_name" 30 | assert len(actual.args) == 1 31 | assert isinstance(actual.args[0], TemplateVariable) 32 | assert actual.args[0].name == "request" 33 | assert actual.kwargs == {} 34 | 35 | 36 | def test_int_arg(): 37 | expected = EvaluatedFunction("set_name", [1], {}) 38 | actual = eval_function("set_name(1)") 39 | 40 | assert expected == actual 41 | 42 | 43 | def test_str_kwarg(): 44 | expected = EvaluatedFunction("set_name", [], {"name": "Bob"}) 45 | actual = eval_function("set_name(name='Bob')") 46 | 47 | assert expected == actual 48 | -------------------------------------------------------------------------------- /src/dj_angles/regex_replacer/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dj_angles.regex_replacer.attribute_replacer import get_attribute_replacements 4 | from dj_angles.regex_replacer.django_tag_replacer import get_django_tag_replacements 5 | from dj_angles.regex_replacer.tag_replacer import get_tag_replacements 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def convert_template(html: str) -> str: 11 | """Gets a list of replacements based on template HTML, replaces the necessary strings, and returns the new string. 12 | 13 | Args: 14 | param html: Template HTML. 15 | 16 | Returns: 17 | The converted template HTML. 18 | """ 19 | 20 | # Replace dj-angles attributes first 21 | for replacement in get_attribute_replacements(html=html): 22 | html = html.replace( 23 | replacement.original, 24 | replacement.replacement, 25 | 1, 26 | ) 27 | 28 | # Replace dj-angles django tags second 29 | for replacement in get_django_tag_replacements(html=html): 30 | html = html.replace( 31 | replacement.original, 32 | replacement.replacement, 33 | 1, 34 | ) 35 | 36 | # Replace dj-angles tags third 37 | for replacement in get_tag_replacements(html=html): 38 | html = html.replace( 39 | replacement.original, 40 | replacement.replacement, 41 | 1, 42 | ) 43 | 44 | return html 45 | -------------------------------------------------------------------------------- /src/dj_angles/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from dj_angles.tags import Tag 5 | 6 | 7 | class InvalidEndTagError(Exception): 8 | """Indicates that a tag has been opened, but not closed correctly.""" 9 | 10 | tag: "Tag" 11 | """Current tag that is being processed.""" 12 | 13 | last_tag: "Tag" 14 | """The previous tag that was processed.""" 15 | 16 | def __init__(self, tag: "Tag", last_tag: "Tag"): 17 | super().__init__() 18 | 19 | self.tag = tag 20 | self.last_tag = last_tag 21 | 22 | 23 | class MissingAttributeError(Exception): 24 | """Indicates that an attribute could not be found.""" 25 | 26 | name: str 27 | """The name of the attribute.""" 28 | 29 | def __init__(self, name: str): 30 | super().__init__() 31 | 32 | self.name = name 33 | 34 | 35 | class DuplicateAttributeError(Exception): 36 | """Indicates that an attribute would be duplicated.""" 37 | 38 | name: str 39 | """The name of the attribute.""" 40 | 41 | def __init__(self, name: str): 42 | super().__init__() 43 | 44 | self.name = name 45 | 46 | 47 | class InvalidAttributeError(Exception): 48 | """Indicates that an attribute is invalid.""" 49 | 50 | name: str 51 | """The name of the attribute.""" 52 | 53 | def __init__(self, name: str, message: str | None = None): 54 | super().__init__(message) 55 | 56 | self.name = name 57 | -------------------------------------------------------------------------------- /src/dj_angles/templatetags/model.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.apps import apps 4 | 5 | from dj_angles.evaluator import Portion 6 | from dj_angles.templatetags.call import CallNode, do_call 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | """ 11 | Global storage of all available models. 12 | """ 13 | models = None 14 | 15 | 16 | def get_models() -> dict: 17 | models = {} 18 | 19 | for app_config in apps.get_app_configs(): 20 | for model in app_config.get_models(): 21 | # TODO: What if model names overlap? 22 | models[model.__name__] = model 23 | 24 | return models 25 | 26 | 27 | def clear_models() -> None: 28 | global models # noqa: PLW0603 29 | models = None 30 | 31 | 32 | class ModelNode(CallNode): 33 | def render(self, context): 34 | global models # noqa: PLW0603 35 | 36 | if models is None: 37 | models = get_models() 38 | 39 | context["__dj_angles_models"] = models 40 | 41 | return super().render(context) 42 | 43 | 44 | def do_model(parser, token) -> ModelNode: 45 | call_node = do_call(parser, token) 46 | 47 | # Models are stored in a special part of the context so it doesn't conflict with other data 48 | # so add this fake object name because we will use it in the render method 49 | call_node.parsed_function.portions.insert(0, Portion(name="__dj_angles_models")) 50 | 51 | return ModelNode(call_node.parsed_function, call_node.context_variable_name) 52 | -------------------------------------------------------------------------------- /tests/dj_angles/tokenizer/test_yield_tokens.py: -------------------------------------------------------------------------------- 1 | from dj_angles.tokenizer import yield_tokens 2 | 3 | 4 | def test_single_quote(): 5 | expected = "'partial.html'" 6 | actual = yield_tokens("'partial.html'", " ") 7 | 8 | assert expected == next(actual) 9 | 10 | 11 | def test_double_quote(): 12 | expected = '"partial.html"' 13 | actual = yield_tokens('"partial.html"', " ") 14 | 15 | assert expected == next(actual) 16 | 17 | 18 | def test_breaking_character(): 19 | expected = ("rel", "'stylesheet'") 20 | actual = yield_tokens("rel='stylesheet'", "=") 21 | 22 | assert expected == tuple(actual) 23 | 24 | 25 | def test_breaking_character_inside_single_quote(): 26 | expected = ("rel", "'style=sheet'") 27 | actual = yield_tokens("rel='style=sheet'", "=") 28 | 29 | assert expected == tuple(actual) 30 | 31 | 32 | def test_breaking_character_inside_parenthesis(): 33 | expected = ("set_name('hello', 'world')", "another_something('ok', 'cool')") 34 | actual = yield_tokens("set_name('hello', 'world') another_something('ok', 'cool')", " ", handle_parenthesis=True) 35 | 36 | assert expected == tuple(actual) 37 | 38 | 39 | def test_breaking_character_inside_multiple_parenthesis(): 40 | expected = ( 41 | "set_name('hello', 'world')", 42 | "another_something((('ok'), ('cool')))", 43 | ) 44 | actual = yield_tokens( 45 | "set_name('hello', 'world') another_something((('ok'), ('cool')))", " ", handle_parenthesis=True 46 | ) 47 | 48 | assert expected == tuple(actual) 49 | -------------------------------------------------------------------------------- /src/dj_angles/template_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.apps import apps 4 | from django.template import TemplateDoesNotExist 5 | from django.template.loaders.app_directories import Loader as AppDirectoriesLoader 6 | 7 | from dj_angles.regex_replacer import convert_template 8 | 9 | 10 | class Loader(AppDirectoriesLoader): 11 | def _get_template_string(self, template_name): 12 | """Get the string content a template.""" 13 | 14 | try: 15 | with open(template_name, encoding=self.engine.file_charset) as f: 16 | return f.read() 17 | except FileNotFoundError as e: 18 | raise TemplateDoesNotExist(template_name) from e 19 | 20 | def get_contents(self, origin) -> str: 21 | """Gets the converted template contents.""" 22 | 23 | template_string = self._get_template_string(origin.name) 24 | converted_template_string = convert_template(template_string) 25 | 26 | return converted_template_string 27 | 28 | def get_dirs(self): 29 | """Gets the template directories. This works like the file loader with `APP_DIRS = True`. 30 | 31 | From https://github.com/wrabit/django-cotton/blob/ab1a98052de48266c62ff226ab0ec85b89d038b6/django_cotton/cotton_loader.py#L59. 32 | """ 33 | 34 | dirs = self.engine.dirs 35 | 36 | for app_config in apps.get_app_configs(): 37 | template_dir = os.path.join(app_config.path, "templates") 38 | 39 | if os.path.isdir(template_dir): 40 | dirs.append(template_dir) 41 | 42 | return dirs 43 | -------------------------------------------------------------------------------- /tests/dj_angles/tags/test_tag.py: -------------------------------------------------------------------------------- 1 | from tests.dj_angles.tags import create_tag 2 | 3 | 4 | def test_get_wrapping_tag_name(): 5 | expected = "dj-include" 6 | 7 | tag = create_tag("") 8 | actual = tag.get_wrapping_tag_name() 9 | 10 | assert expected == actual 11 | 12 | 13 | def test_get_wrapping_tag_name_with_name(): 14 | expected = "dj-hello" 15 | 16 | tag = create_tag("") 17 | actual = tag.get_wrapping_tag_name(name="hello") 18 | 19 | assert expected == actual 20 | 21 | 22 | def test_get_wrapping_tag_name_component(): 23 | expected = "dj-fake-partial" 24 | 25 | tag = create_tag("") 26 | actual = tag.get_wrapping_tag_name() 27 | 28 | assert expected == actual 29 | 30 | 31 | def test_get_wrapping_tag_name_component_with_key(): 32 | expected = "dj-partial-1" 33 | 34 | tag = create_tag("") 35 | actual = tag.get_wrapping_tag_name() 36 | 37 | assert expected == actual 38 | 39 | 40 | def test_default_mapping(settings): 41 | expected = "{% include 'fake-partial.html' %}" 42 | 43 | html = "" 44 | 45 | tag = create_tag(html) 46 | actual = tag.get_django_template_tag() 47 | 48 | assert expected == actual 49 | 50 | 51 | def test_get_attribute_value_or_first_key(): 52 | expected = "'test1'" 53 | 54 | html = "" 55 | 56 | tag = create_tag(html) 57 | actual = tag.get_attribute_value_or_first_key("template") 58 | 59 | assert expected == actual 60 | -------------------------------------------------------------------------------- /src/dj_angles/tokenizer.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | 3 | 4 | def yield_tokens( 5 | s: str, breaking_character: str, *, handle_quotes: bool = True, handle_parenthesis: bool = False 6 | ) -> Generator[str, None, None]: 7 | """Yields tokens from `s` by reading the string from left to right until a `breaking_character`, 8 | then continuing to read the `s` until the next `breaking_character`, ad infinitum. 9 | 10 | Args: 11 | param s: The string to parse. 12 | param breaking_character: The character that signifies the end of the token. 13 | param handle_quotes: Whether to ignore the breaking_character when inside single or double quotes. 14 | param handle_parenthesis: Whether to ignore the breaking_character when inside parenthesis. 15 | 16 | Returns: 17 | A generator of tokens. 18 | """ 19 | 20 | in_double_quote = False 21 | in_single_quote = False 22 | parenthesis_count = 0 23 | token = "" 24 | 25 | for c in s: 26 | if c == "'" and handle_quotes: 27 | in_single_quote = not in_single_quote 28 | elif c == '"' and handle_quotes: 29 | in_double_quote = not in_double_quote 30 | elif c == "(" and handle_parenthesis: 31 | parenthesis_count += 1 32 | elif c == ")" and handle_parenthesis: 33 | parenthesis_count -= 1 34 | 35 | if c == breaking_character and not in_single_quote and not in_double_quote and parenthesis_count == 0: 36 | yield token 37 | token = "" 38 | else: 39 | token += c 40 | 41 | if token: 42 | yield token 43 | -------------------------------------------------------------------------------- /src/dj_angles/templates.py: -------------------------------------------------------------------------------- 1 | from django.template import Template 2 | from django.template.exceptions import TemplateDoesNotExist 3 | from django.template.loader import select_template 4 | 5 | from dj_angles.strings import dequotify 6 | 7 | 8 | def get_template(template_file: str) -> Template | None: 9 | """Check for the template file by looking for different template file variations. 10 | 11 | Currently, the only other variation is looking for the template file with an underscore 12 | in front (a typical convention for partials). 13 | 14 | Args: 15 | param template_file: The original template file name. 16 | 17 | Returns: 18 | A constructed template object for the template file or `None` if it cannot be found. 19 | """ 20 | 21 | template = None 22 | 23 | template_file = dequotify(template_file) 24 | template_file_list = [template_file] 25 | 26 | if not template_file.startswith("_"): 27 | if "/" in template_file: 28 | # Grab the last part and prepend an underscore to it, then reassemble the path 29 | # TODO: Maybe a better way to do this with pathlib or something OS-agnostic 30 | template_pieces = template_file.split("/") 31 | file_name = template_pieces.pop(-1) 32 | template_pieces.append(f"_{file_name}") 33 | template_file = "/".join(template_pieces) 34 | 35 | template_file_list.append(template_file) 36 | else: 37 | template_file_list.append(f"_{template_file}") 38 | 39 | try: 40 | template = select_template(template_file_list) 41 | except TemplateDoesNotExist: 42 | pass 43 | 44 | return template 45 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/test_dj_angles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import Context, Template 3 | from example.book.models import Book 4 | 5 | 6 | def test_call_tag_with_context_variable(): 7 | template = Template(""" 8 | {% call some_function as result %} 9 | 10 | {{ result }} 11 | """) 12 | context = Context({"some_function": lambda: "expected output"}) 13 | rendered = template.render(context) 14 | 15 | assert "expected output" in rendered 16 | 17 | 18 | def test_call_tag_with_context_variable_no_output(): 19 | template = Template(""" 20 | {% call some_function as result %} 21 | """) 22 | context = Context({"some_function": lambda: "expected output"}) 23 | rendered = template.render(context) 24 | 25 | assert "expected output" not in rendered 26 | 27 | 28 | def test_call_tag(): 29 | template = Template(""" 30 | {% call some_function %} 31 | """) 32 | context = Context({"some_function": lambda: "expected output"}) 33 | rendered = template.render(context) 34 | 35 | assert "expected output" in rendered 36 | 37 | 38 | def test_call_tag_obj(): 39 | template = Template(""" 40 | {% call some_function %} 41 | """) 42 | context = Context({"some_function": lambda: 123}) 43 | rendered = template.render(context) 44 | 45 | assert "123" in rendered 46 | 47 | 48 | @pytest.mark.django_db 49 | def test_model_tag(): 50 | Book.objects.create(id=1, title="Huckleberry Finn") 51 | 52 | template = Template(""" 53 | {% model Book.objects.filter(id=1).first() as book %} 54 | 55 | {{ book.title }} 56 | """) 57 | context = Context({"Book": Book}) 58 | rendered = template.render(context) 59 | 60 | assert "Huckleberry Finn" in rendered 61 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/django/test_map_block.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dj_angles.exceptions import MissingAttributeError 4 | from dj_angles.mappers.django import map_block 5 | from tests.dj_angles.tags import create_tag 6 | 7 | 8 | def test_string(): 9 | expected = "{% block content %}" 10 | 11 | html = "" 12 | tag = create_tag(html) 13 | 14 | actual = map_block(tag=tag) 15 | 16 | assert actual == expected 17 | 18 | 19 | def test_is_end(): 20 | expected = "{% endblock %}" 21 | 22 | html = "" 23 | tag = create_tag(html) 24 | 25 | actual = map_block(tag=tag) 26 | 27 | assert actual == expected 28 | 29 | 30 | def test_name_attribute(): 31 | expected = "{% block content %}" 32 | 33 | html = "" 34 | tag = create_tag(html) 35 | 36 | actual = map_block(tag=tag) 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_name_self_closing(): 42 | expected = "{% block content %}{% endblock content %}" 43 | 44 | html = "" 45 | tag = create_tag(html) 46 | 47 | actual = map_block(tag=tag) 48 | 49 | assert actual == expected 50 | 51 | 52 | def test_name_attribute_self_closing(): 53 | expected = "{% block content %}{% endblock content %}" 54 | 55 | html = "" 56 | tag = create_tag(html) 57 | 58 | actual = map_block(tag=tag) 59 | 60 | assert actual == expected 61 | 62 | 63 | def test_missing_name_throws_exception(): 64 | html = "" 65 | tag = create_tag(html) 66 | 67 | with pytest.raises(MissingAttributeError) as e: 68 | map_block(tag=tag) 69 | 70 | assert e.value.name == "name" 71 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | import toml 4 | 5 | # -- Project information 6 | 7 | project = "dj-angles" 8 | copyright = "2024, Adam Hill" # noqa: A001 9 | author = "Adam Hill" 10 | 11 | pyproject = toml.load("../../pyproject.toml") 12 | version = pyproject["project"]["version"] 13 | release = version 14 | 15 | 16 | # -- General configuration 17 | 18 | extensions = [ 19 | "sphinx.ext.duration", 20 | "sphinx.ext.doctest", 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.autosummary", 23 | "sphinx.ext.intersphinx", 24 | "myst_parser", 25 | "sphinx_copybutton", 26 | "sphinx.ext.napoleon", 27 | "sphinx.ext.autosectionlabel", 28 | "sphinx_inline_tabs", 29 | "autoapi.extension", 30 | ] 31 | 32 | intersphinx_mapping = { 33 | "python": ("https://docs.python.org/3/", None), 34 | "sphinx": ("https://www.sphinx-doc.org/en/master/", None), 35 | } 36 | intersphinx_disabled_domains = ["std"] 37 | 38 | templates_path = ["_templates"] 39 | 40 | # -- Options for HTML output 41 | 42 | html_theme = "furo" 43 | 44 | # -- Options for EPUB output 45 | epub_show_urls = "footnote" 46 | 47 | autosectionlabel_prefix_document = True 48 | autosectionlabel_maxdepth = 3 49 | 50 | myst_heading_anchors = 3 51 | myst_enable_extensions = ["linkify", "colon_fence"] 52 | 53 | 54 | autoapi_dirs = [ 55 | "../../src/dj_angles", 56 | ] 57 | autoapi_root = "api" 58 | autoapi_add_toctree_entry = False 59 | autoapi_generate_api_docs = True 60 | # autoapi_keep_files = True # useful for debugging generated errors 61 | autoapi_options = [ 62 | "members", 63 | "undoc-members", 64 | "show-inheritance", 65 | ] 66 | autoapi_type = "python" 67 | autodoc_typehints = "signature" 68 | -------------------------------------------------------------------------------- /tests/dj_angles/caseconverters/test_kebabify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dj_angles.caseconverter import kebabify 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "test_case, expect", 8 | [ 9 | # With punctuation. 10 | ("Hello, world!", "hello-world"), 11 | # Camel cased 12 | ("helloWorld", "hello-world"), 13 | # Joined by delimeter. 14 | ("Hello-World", "hello-world"), 15 | # Cobol cased 16 | ("HELLO-WORLD", "hello-world"), 17 | # Without punctuation. 18 | ("Hello world", "hello-world"), 19 | # Repeating single delimeter 20 | ("Hello World", "hello-world"), 21 | # Repeating delimeters of different types 22 | ("Hello -__ World", "hello-world"), 23 | # Wrapped in delimeter 24 | (" hello world ", "hello-world"), 25 | # End in capital letter 26 | ("hellO", "hell-o"), 27 | # Long sentence with punctuation 28 | ( 29 | r"the quick !b@rown fo%x jumped over the laZy Do'G", 30 | "the-quick-brown-fox-jumped-over-the-la-zy-do-g", 31 | ), 32 | # Alternating character cases 33 | ("heLlo WoRld", "he-llo-wo-rld"), 34 | ], 35 | ) 36 | def test_default(test_case, expect): 37 | assert kebabify(test_case) == expect 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "test_case, expect", 42 | [ 43 | # With punctuation. 44 | ("component/partial", "component/partial"), 45 | # With extension 46 | ("component/partial.html", "component/partial.html"), 47 | # Pascal cased 48 | ("PartialOne", "partial-one"), 49 | ], 50 | ) 51 | def test_keep_punctuation(test_case, expect): 52 | assert kebabify(test_case, strip_punctuation=False) == expect 53 | -------------------------------------------------------------------------------- /docs/source/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | Settings can be configured via an `ANGLES` dictionary in `settings.py`. 4 | 5 | ## `default_mapper` 6 | 7 | A default mapper. Useful for tighter integration with other component libraries. `String` which is an import path. Defaults to `"dj_angles.mappers.angles.default_mapper"`. 8 | 9 | Example settings: 10 | 11 | ```python 12 | # settings.py 13 | 14 | ANGLES = { 15 | "default_mapper": "dj_angles.mappers.angles.default_mapper" 16 | } 17 | ``` 18 | 19 | ### `"dj_angles.mappers.angles.default_mapper"` (the default) 20 | 21 | ```html 22 | 23 | ``` 24 | 25 | Would be transpiled to the following. 26 | 27 | ```html 28 | {% include 'partial.html' %} 29 | ``` 30 | 31 | ### `"dj_angles.mappers.thirdparty.map_bird"` 32 | 33 | ```html 34 | 35 | ``` 36 | 37 | Would be transpiled to the following. 38 | 39 | ```html 40 | {% bird partial / %} 41 | ``` 42 | 43 | ## `initial_attribute_regex` 44 | 45 | The regex for `dj-angles` attributes, e.g. `dj-if`. `Raw string` which defaults to `r"(dj-)"`. 46 | 47 | ## `initial_tag_regex` 48 | 49 | The regex to know that particular HTML should be parsed by `dj-angles`. `Raw string` which defaults to `r"(dj-)"`. 50 | 51 | ## `kebab_case_tag` 52 | 53 | Makes the tag kebab case based on upper case letter, e.g. "PartialOne" would get converted to "partial-one". `Boolean` which defaults to `True`. 54 | 55 | ## `lower_case_tag` 56 | 57 | Lower-cases the tag. Deprecated and superseded by `kebab_case_tag`. `Boolean` which defaults to `False`. 58 | 59 | ## `map_explicit_tags_only` 60 | 61 | Do not fallback to the default if a mapper cannot be found. `Boolean` which defaults to `False`. 62 | 63 | ## `mappers` 64 | 65 | Custom additional mappers. `Dictionary` which defaults to `{}`. More details about [mappers](mappers.md). 66 | 67 | ## `slots_enabled` 68 | 69 | Enables [slots](components.md#slots) functionality for components. `Boolean` which defaults to `False`. 70 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/thirdparty/test_map_partialdef.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.thirdparty import map_partial 2 | from tests.dj_angles.tags import create_tag 3 | 4 | 5 | def test(): 6 | expected = "{% partialdef test1 %}" 7 | 8 | html = "" 9 | tag = create_tag(html) 10 | 11 | actual = map_partial(tag=tag) 12 | 13 | assert actual == expected 14 | 15 | 16 | def test_no_name(): 17 | expected = "{% partialdef test1 %}" 18 | 19 | html = "" 20 | tag = create_tag(html) 21 | 22 | actual = map_partial(tag=tag) 23 | 24 | assert actual == expected 25 | 26 | 27 | def test_is_closing(): 28 | expected = "{% endpartialdef %}" 29 | 30 | html = "" 31 | tag = create_tag(html) 32 | 33 | actual = map_partial(tag=tag) 34 | 35 | assert actual == expected 36 | 37 | 38 | def test_use(): 39 | expected = "{% partial test1 %}" 40 | 41 | html = "" 42 | tag = create_tag(html) 43 | 44 | actual = map_partial(tag=tag) 45 | 46 | assert actual == expected 47 | 48 | 49 | def test_inline(): 50 | expected = "{% partialdef test1 inline %}" 51 | 52 | html = "" 53 | tag = create_tag(html) 54 | 55 | actual = map_partial(tag=tag) 56 | 57 | assert actual == expected 58 | 59 | 60 | def test_get_django_template_tag(settings): 61 | settings.ANGLES["default_mapper"] = "dj_angles.mappers.map_partial" 62 | 63 | expected = "{% partialdef test1 %}" 64 | 65 | html = "" 66 | tag = create_tag(html) 67 | 68 | actual = tag.get_django_template_tag() 69 | 70 | assert actual == expected 71 | 72 | 73 | def test_get_django_template_tag_is_end(settings): 74 | settings.ANGLES["default_mapper"] = "dj_angles.mappers.map_partial" 75 | 76 | expected = "{% endpartialdef %}" 77 | 78 | html = "" 79 | 80 | tag = create_tag(html) 81 | actual = tag.get_django_template_tag() 82 | 83 | assert expected == actual 84 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/angles/test_map_form.py: -------------------------------------------------------------------------------- 1 | from dj_angles.mappers.angles import map_form 2 | from tests.dj_angles.tags import create_tag 3 | 4 | 5 | def test_start_tag(): 6 | expected = "
" 7 | 8 | html = "" 9 | tag = create_tag(html) 10 | actual = map_form(tag=tag) 11 | 12 | assert actual == expected 13 | 14 | 15 | def test_csrf(): 16 | expected = "{% csrf_token %}" 17 | 18 | html = "" 19 | tag = create_tag(html) 20 | actual = map_form(tag=tag) 21 | 22 | assert actual == expected 23 | 24 | 25 | def test_ajax(): 26 | expected = "" 27 | 28 | html = "" 29 | tag = create_tag(html) 30 | actual = map_form(tag=tag) 31 | 32 | assert actual == expected 33 | 34 | 35 | def test_swap(): 36 | expected = "" 37 | 38 | html = "" 39 | tag = create_tag(html) 40 | actual = map_form(tag=tag) 41 | 42 | assert actual == expected 43 | 44 | 45 | def test_additional_attributes(): 46 | expected = "" 47 | 48 | html = "" 49 | tag = create_tag(html) 50 | actual = map_form(tag=tag) 51 | 52 | assert actual == expected 53 | 54 | 55 | def test_end(): 56 | expected = "" 57 | 58 | tag = create_tag("") 59 | tag.start_tag = create_tag("") 60 | actual = map_form(tag=tag) 61 | 62 | assert actual == expected 63 | 64 | 65 | def test_end_ajax(): 66 | expected = "" 67 | 68 | tag = create_tag("") 69 | tag.start_tag = create_tag("") 70 | actual = map_form(tag=tag) 71 | 72 | assert actual == expected 73 | -------------------------------------------------------------------------------- /tests/dj_angles/mappers/include/test_get_include_template_file.py: -------------------------------------------------------------------------------- 1 | from tests.dj_angles.tags import create_tag 2 | 3 | from dj_angles.mappers.include import get_include_template_file 4 | 5 | 6 | def test_first_attribute(): 7 | expected = "'partial.html'" 8 | 9 | html = "" 10 | tag = create_tag(html) 11 | 12 | actual = get_include_template_file(tag=tag) 13 | 14 | assert actual == expected 15 | 16 | 17 | def test_first_attribute_with_extension(): 18 | expected = "'partial.html'" 19 | 20 | html = "" 21 | tag = create_tag(html) 22 | 23 | actual = get_include_template_file(tag=tag) 24 | 25 | assert actual == expected 26 | 27 | 28 | def test_first_attribute_double_quote(): 29 | expected = '"partial.html"' 30 | 31 | html = '' 32 | tag = create_tag(html) 33 | 34 | actual = get_include_template_file(tag=tag) 35 | 36 | assert actual == expected 37 | 38 | 39 | def test_template_attribute(): 40 | expected = "'partial.html'" 41 | 42 | html = "" 43 | tag = create_tag(html) 44 | 45 | actual = get_include_template_file(tag=tag) 46 | 47 | assert actual == expected 48 | 49 | 50 | def test_template_attribute_with_extension(): 51 | expected = "'partial.html'" 52 | 53 | html = "" 54 | tag = create_tag(html) 55 | 56 | actual = get_include_template_file(tag=tag) 57 | 58 | assert actual == expected 59 | 60 | 61 | def test_template_attribute_double_quote(): 62 | expected = '"partial.html"' 63 | 64 | html = '' 65 | tag = create_tag(html) 66 | 67 | actual = get_include_template_file(tag=tag) 68 | 69 | assert actual == expected 70 | 71 | 72 | def test_fallback(): 73 | expected = "'partial.html'" 74 | 75 | html = "" 76 | tag = create_tag(html) 77 | 78 | actual = get_include_template_file(tag=tag) 79 | 80 | assert actual == expected 81 | 82 | 83 | def test_src_attribute(): 84 | expected = '"partial.html"' 85 | 86 | html = '' 87 | tag = create_tag(html) 88 | 89 | actual = get_include_template_file(tag=tag) 90 | 91 | assert actual == expected 92 | -------------------------------------------------------------------------------- /docs/source/examples.md: -------------------------------------------------------------------------------- 1 | # Config Examples 2 | 3 | `dj-angles` is pretty flexible when determining what HTML to parse. Here are some examples to show what can be done. [Custom mappers](mappers.md) can also be setup to handle additional use cases. 4 | 5 | ## Tags without a dj- prefix 6 | 7 | ```python 8 | # settings.py 9 | 10 | ANGLES = { 11 | "initial_tag_regex": r"(?=\w)", # lookahead match anything that starts with a letter 12 | "map_explicit_tags_only": True, # only map tags we know about to prevent mapping standard HTML tags 13 | } 14 | ``` 15 | 16 | ```text 17 | 18 | 19 | 20 | ``` 21 | 22 | This would transpile to the following. 23 | 24 | ```text 25 | {% block content %} 26 | {% include 'partial.html' %} 27 | {% endblock content %} 28 | ``` 29 | 30 | ## React-style include 31 | 32 | ```python 33 | # settings.py 34 | 35 | ANGLES = { 36 | "initial_tag_regex": r"(?=[A-Z])", # lookahead match upper-case letter 37 | } 38 | ``` 39 | 40 | ```html 41 | 42 | ``` 43 | 44 | This would transpile to the following. 45 | 46 | ```text 47 | {% include 'partial-one.html' %} 48 | ``` 49 | 50 | ## Tags with a special character 51 | 52 | ```python 53 | # settings.py 54 | 55 | ANGLES = { 56 | "initial_tag_regex": r"(\$)" 57 | } 58 | ``` 59 | 60 | ```text 61 | <$partial /> 62 | ``` 63 | 64 | This would transpile to the following. 65 | 66 | ```text 67 | {% include 'partial.html' %} 68 | ``` 69 | 70 | ## Attributes without a dj- prefix 71 | 72 | ```python 73 | # settings.py 74 | 75 | ANGLES = { 76 | "initial_attribute_regex": r"(?=\w)", # lookahead match anything that starts with a letter 77 | } 78 | ``` 79 | 80 | ```text 81 |
Example
82 | ``` 83 | 84 | This would transpile to the following. 85 | 86 | ```text 87 | {% if True %}
Example
{% endif %} 88 | ``` 89 | 90 | 91 | ## Attributes with a special character 92 | 93 | ```python 94 | # settings.py 95 | 96 | ANGLES = { 97 | "initial_attribute_regex": r"(:)", 98 | } 99 | ``` 100 | 101 | ```text 102 |
Example
103 | ``` 104 | 105 | This would transpile to the following. 106 | 107 | ```text 108 | {% if True %}
Example
{% endif %} 109 | ``` 110 | -------------------------------------------------------------------------------- /docs/source/mappers.md: -------------------------------------------------------------------------------- 1 | # Mappers 2 | 3 | ```{tip} 4 | Understanding the concept of mappers is not required for basic uses of `dj-angles`. It is only needed to support additional HTML tags or for custom implementations. 5 | ``` 6 | 7 | ## Basic flow 8 | 9 | `dj-angles` is built on the basic flow of: 10 | 1. parse template HTML with regex 11 | 2. look up a mapper for any matches found 12 | 3. call mapper appropriately 13 | 14 | `dj-angles` includes a dictionary of built-in mappers. However, custom mappers can be added to handle other use cases. 15 | 16 | ## Custom mappers 17 | 18 | All of the mappers are stored in a dictionary. 19 | 20 | The key of the mapper dictionary is a string and is the text match of the regex after the `initial_tag_regex`, i.e. for the default `initial_tag_regex` of `r"(dj-)"`, the key would be the result after "dj-" until a space or a ">". 21 | 22 | For example, if `""` was in the HTML, `"include"` would be looked up in the mapper dictionary to determine what to do with that tag. 23 | 24 | The value of the mapper dictionary can either be a string or a callable. 25 | 26 | ### string value 27 | 28 | When the dictionary value is a string, it replaces the `initial_tag_regex` plus the key, and puts it between "{%", arguments, and then "%}". 29 | 30 | ```python 31 | # settings.py 32 | 33 | ANGLES = { 34 | "mappers": { 35 | "component": "include", 36 | }, 37 | } 38 | ``` 39 | 40 | ```text 41 | 42 | ``` 43 | 44 | Would transpile to the following Django template. 45 | 46 | ```text 47 | {% include 'partial.html' %} 48 | ``` 49 | 50 | ### Callable value 51 | 52 | When the dictionary value is a callable, the result is dictated by the output of the mapper function. The callable has one argument, `Tag`, which encapsulates information about the matched tag that can be useful in building custom functionality, e.g. `tag_name`, `is_end`, `is_self_closing`, etc. 53 | 54 | ```python 55 | # settings.py 56 | 57 | from dj_angles import Tag 58 | 59 | def map_text(tag: Tag) -> str: 60 | return "This is some text." 61 | 62 | def map_hello(tag: Tag) -> str: 63 | return f"

{tag.tag_name.upper()}! {tag.template_tag_args}

" 64 | 65 | ANGLES = { 66 | "mappers": { 67 | "text": map_text, 68 | "hello": map_hello, 69 | }, 70 | } 71 | ``` 72 | 73 | ```text 74 | 75 | 76 | 77 | ``` 78 | 79 | Would transpile to the following Django template. 80 | 81 | ```html 82 | This is some text. 83 | 84 |

HELLO! Goodbye!

85 | ``` 86 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from dj_angles.mappers.mapper import clear_tag_map 5 | from dj_angles.templatetags.model import clear_models 6 | 7 | 8 | def pytest_configure(): 9 | settings.configure( 10 | INSTALLED_APPS=[ 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "dj_angles", 14 | "example.book.apps.Config", 15 | ], 16 | TEMPLATES=[ 17 | { 18 | "BACKEND": "django.template.backends.django.DjangoTemplates", 19 | "DIRS": [ 20 | "tests/templates", 21 | ], 22 | "OPTIONS": { 23 | "builtins": [ 24 | "django_bird.templatetags.django_bird", 25 | "dj_angles.templatetags.dj_angles", 26 | ], 27 | "context_processors": [ 28 | "django.template.context_processors.debug", 29 | "django.template.context_processors.request", 30 | "django.contrib.auth.context_processors.auth", 31 | "django.contrib.messages.context_processors.messages", 32 | ], 33 | "loaders": [ 34 | ( 35 | "django.template.loaders.cached.Loader", 36 | [ 37 | "dj_angles.template_loader.Loader", 38 | "django_bird.loader.BirdLoader", 39 | "django.template.loaders.filesystem.Loader", 40 | "django.template.loaders.app_directories.Loader", 41 | ], 42 | ) 43 | ], 44 | }, 45 | }, 46 | ], 47 | SECRET_KEY="this-is-a-secret", 48 | DATABASES={ 49 | "default": { 50 | "ENGINE": "django.db.backends.sqlite3", 51 | } 52 | }, 53 | MIDDLEWARE=( 54 | "dj_angles.middleware.RequestMethodMiddleware", 55 | "dj_angles.middleware.RequestAJAXMiddleware", 56 | ), 57 | ANGLES={}, 58 | ) 59 | 60 | 61 | @pytest.fixture(autouse=True) 62 | def reset_settings(settings): 63 | # Make sure that ANGLES is empty before every test 64 | settings.ANGLES = {} 65 | 66 | # Clear the caches before every test 67 | clear_tag_map() 68 | clear_models() 69 | 70 | # Run test 71 | yield 72 | -------------------------------------------------------------------------------- /docs/source/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | 1. Create a new Django project or `cd` to an existing project 4 | 1. `pip install dj-angles` 5 | 1. Edit `TEMPLATES` in `settings.py` and add `"dj_angles.template_loader.Loader",` as the first loader. Note: you might need to add the `"loaders"` key since it is not there by default (https://docs.djangoproject.com/en/stable/ref/templates/api/#django.template.loaders.cached.Loader). Also remove the `APP_DIRS` setting. 6 | 7 | ```python 8 | # settings.py 9 | 10 | ... 11 | TEMPLATES = [{ 12 | "BACKEND": "django.template.backends.django.DjangoTemplates", 13 | # "APP_DIRS": True, # this cannot be specified if OPTIONS.loaders is explicitly set 14 | "DIRS": [], 15 | "OPTIONS": { 16 | "context_processors": [ 17 | "django.template.context_processors.request", 18 | "django.template.context_processors.debug", 19 | "django.template.context_processors.static", 20 | ], 21 | "loaders": [ 22 | ( 23 | "django.template.loaders.cached.Loader", 24 | [ 25 | "dj_angles.template_loader.Loader", # this is required for `dj-angles` 26 | "django.template.loaders.filesystem.Loader", 27 | "django.template.loaders.app_directories.Loader", 28 | ], 29 | ) 30 | ], 31 | }, 32 | }] 33 | ``` 34 | 35 | ## Template Tags 36 | 37 | `dj-angles` includes regular Django template tags that can be used even if not using the `dj-angles` template loader. 38 | 39 | ```python 40 | # settings.py 41 | 42 | ... 43 | INSTALLED_APPS = [ 44 | ... 45 | "dj_angles", 46 | ] 47 | ``` 48 | 49 | They can be loaded in any template by using `{% load dj_angles %}`. Or `"dj_angles.templatetags.dj_angles"` can be added to template built-ins in `settings.py` to make them available in all templates automatically. 50 | 51 | ```python 52 | # settings.py 53 | 54 | ... 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [BASE_DIR / "templates"], 59 | "OPTIONS": { 60 | ... 61 | "builtins": [ 62 | "dj_angles.templatetags.dj_angles", 63 | ], 64 | }, 65 | }, 66 | ] 67 | ``` 68 | 69 | ## Middleware 70 | 71 | `dj-angles` includes middleware for checking the request method and whether the request is AJAX. 72 | 73 | ```python 74 | # settings.py 75 | 76 | ... 77 | MIDDLEWARE = [ 78 | ... 79 | "dj_angles.middleware.RequestMethodMiddleware", 80 | "dj_angles.middleware.RequestAJAXMiddleware", 81 | ] 82 | ``` 83 | 84 | ## Scripts 85 | 86 | Add scripts for custom elements. 87 | 88 | ```html 89 | 90 | {% load dj_angles %} 91 | 92 | {% dj_angles_scripts %} 93 | ``` 94 | -------------------------------------------------------------------------------- /src/dj_angles/templatetags/template.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.template import Node, NodeList, TemplateSyntaxError 4 | 5 | from dj_angles.evaluator import ParsedFunction 6 | from dj_angles.tokenizer import yield_tokens 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class NodeListRenderer: 12 | def __init__(self, nodelist: NodeList, parsed_function: ParsedFunction, *, include_context: bool = False): 13 | self.nodelist = nodelist 14 | self.parsed_function = parsed_function 15 | self.include_context = include_context 16 | 17 | def render(self, context: dict) -> str: 18 | return str(self.nodelist.render(context)) 19 | 20 | 21 | class TemplateNode(Node): 22 | def __init__(self, nodelist: NodeList, parsed_function: ParsedFunction, *, include_context: bool = False): 23 | self.nodelist = nodelist 24 | self.parsed_function = parsed_function 25 | self.include_context = include_context 26 | 27 | def render(self, context): 28 | """Execute the function with the provided arguments and stores the results in context.""" 29 | 30 | if len(self.parsed_function.portions) > 1: 31 | raise TemplateSyntaxError("Invalid template renderer") 32 | 33 | last_portion = self.parsed_function.portions[-1] 34 | 35 | context.push( 36 | { 37 | last_portion.name: NodeListRenderer( 38 | self.nodelist, self.parsed_function, include_context=self.include_context 39 | ) 40 | } 41 | ) 42 | 43 | # Must render a string 44 | return "" 45 | 46 | 47 | def do_template(parser, token) -> TemplateNode: 48 | """Parses the token to get all the pieces needed to render the template. 49 | 50 | Args: 51 | parser: The template parser. 52 | token: The token to parse. 53 | 54 | Returns: 55 | TemplateNode: Handles rendering the template. 56 | """ 57 | 58 | # Get the nodelist up until the endtemplate tag 59 | nodelist = parser.parse(("endtemplate",)) 60 | parser.delete_first_token() 61 | 62 | """ 63 | Split the contents by whitespace. Examples: 64 | - "template some_function(arg1, arg2)" 65 | """ 66 | contents = list(yield_tokens(token.contents, " ", handle_quotes=True, handle_parenthesis=True)) 67 | 68 | # The first content is always the name of the tag, so pop it off 69 | contents.pop(0) 70 | 71 | if len(contents) < 1: 72 | raise TemplateSyntaxError("template requires at least 1 argument") 73 | 74 | parsed_function = ParsedFunction(contents[0]) 75 | 76 | include_context = False 77 | 78 | if len(contents) == 3 and contents[1] == "with" and contents[2] == "context": # noqa: PLR2004 79 | include_context = True 80 | 81 | return TemplateNode(nodelist, parsed_function, include_context=include_context) 82 | -------------------------------------------------------------------------------- /docs/source/integrations/django-template-partials.md: -------------------------------------------------------------------------------- 1 | # django-template-partials 2 | 3 | >Reusable named inline partials for the Django Template Language. 4 | 5 | - 📦 [https://pypi.org/project/django-template-partials/](https://pypi.org/project/django-template-partials/) 6 | - 🛠️ [https://github.com/carltongibson/django-template-partials](https://github.com/carltongibson/django-template-partials) 7 | 8 | ## Installation 9 | 10 | Using the auto settings of `django-template-partials` will conflict with `dj-angles`. You will need to install it using the [advanced configuration](https://github.com/carltongibson/django-template-partials#advanced-configuration). 11 | 12 | ```python 13 | # settings.py 14 | 15 | INSTALLED_APPS = { 16 | ... 17 | "template_partials.apps.SimpleAppConfig", 18 | ... 19 | } 20 | 21 | TEMPLATES = [ 22 | { 23 | "BACKEND": "django.template.backends.django.DjangoTemplates", 24 | "DIRS": [BASE_DIR / "templates"], 25 | "OPTIONS": { 26 | "context_processors": [ 27 | ... 28 | ], 29 | "loaders": [ 30 | ( 31 | "template_partials.loader.Loader", 32 | [ 33 | ( 34 | "django.template.loaders.cached.Loader", 35 | [ 36 | "dj_angles.template_loader.Loader", 37 | "django.template.loaders.filesystem.Loader", 38 | "django.template.loaders.app_directories.Loader", 39 | ], 40 | ) 41 | ], 42 | ) 43 | ], 44 | "builtins": [ 45 | ... 46 | "template_partials.templatetags.partials", 47 | ... 48 | ], 49 | }, 50 | }, 51 | ] 52 | ``` 53 | 54 | ## Define a partial 55 | 56 | Define a template partial. 57 | 58 | ```html 59 | 60 | TEST-PARTIAL-CONTENT 61 | 62 | ``` 63 | 64 | is equivalent to: 65 | 66 | ```html 67 | {% partialdef test-partial %} 68 | TEST-PARTIAL-CONTENT 69 | {% endpartialdef %} 70 | ``` 71 | 72 | ## Use a partial 73 | 74 | Render a defined template partial. 75 | 76 | ```html 77 | 78 | ``` 79 | 80 | is equivalent to: 81 | 82 | ```html 83 | {% partial test-partial %} 84 | ``` 85 | 86 | ## Define an inline partial 87 | 88 | Defines a template partial and renders it in-place. 89 | 90 | ```html 91 | 92 | TEST-PARTIAL-CONTENT 93 | 94 | ``` 95 | 96 | is equivalent to: 97 | 98 | ```html 99 | {% partialdef test-partial inline %} 100 | TEST-PARTIAL-CONTENT 101 | {% endpartialdef %} 102 | ``` -------------------------------------------------------------------------------- /tests/dj_angles/middleware/test_request_method.py: -------------------------------------------------------------------------------- 1 | from dj_angles.middleware import RequestMethodMiddleware 2 | 3 | 4 | def test_get(rf): 5 | middleware = RequestMethodMiddleware(lambda _: None) 6 | 7 | request = rf.get("/") 8 | middleware.__call__(request) 9 | 10 | assert request.is_get 11 | assert not request.is_post 12 | assert not request.is_patch 13 | assert not request.is_head 14 | assert not request.is_put 15 | assert not request.is_delete 16 | assert not request.is_trace 17 | 18 | 19 | def test_post(rf): 20 | middleware = RequestMethodMiddleware(lambda _: None) 21 | 22 | request = rf.post("/") 23 | middleware.__call__(request) 24 | 25 | assert not request.is_get 26 | assert request.is_post 27 | assert not request.is_patch 28 | assert not request.is_head 29 | assert not request.is_put 30 | assert not request.is_delete 31 | assert not request.is_trace 32 | 33 | 34 | def test_patch(rf): 35 | middleware = RequestMethodMiddleware(lambda _: None) 36 | 37 | request = rf.patch("/") 38 | middleware.__call__(request) 39 | 40 | assert not request.is_get 41 | assert not request.is_post 42 | assert request.is_patch 43 | assert not request.is_head 44 | assert not request.is_put 45 | assert not request.is_delete 46 | assert not request.is_trace 47 | 48 | 49 | def test_head(rf): 50 | middleware = RequestMethodMiddleware(lambda _: None) 51 | 52 | request = rf.head("/") 53 | middleware.__call__(request) 54 | 55 | assert not request.is_get 56 | assert not request.is_post 57 | assert not request.is_patch 58 | assert request.is_head 59 | assert not request.is_put 60 | assert not request.is_delete 61 | assert not request.is_trace 62 | 63 | 64 | def test_put(rf): 65 | middleware = RequestMethodMiddleware(lambda _: None) 66 | 67 | request = rf.put("/") 68 | middleware.__call__(request) 69 | 70 | assert not request.is_get 71 | assert not request.is_post 72 | assert not request.is_patch 73 | assert not request.is_head 74 | assert request.is_put 75 | assert not request.is_delete 76 | assert not request.is_trace 77 | 78 | 79 | def test_delete(rf): 80 | middleware = RequestMethodMiddleware(lambda _: None) 81 | 82 | request = rf.delete("/") 83 | middleware.__call__(request) 84 | 85 | assert not request.is_get 86 | assert not request.is_post 87 | assert not request.is_patch 88 | assert not request.is_head 89 | assert not request.is_put 90 | assert request.is_delete 91 | assert not request.is_trace 92 | 93 | 94 | def test_trace(rf): 95 | middleware = RequestMethodMiddleware(lambda _: None) 96 | 97 | request = rf.trace("/") 98 | middleware.__call__(request) 99 | 100 | assert not request.is_get 101 | assert not request.is_post 102 | assert not request.is_patch 103 | assert not request.is_head 104 | assert not request.is_put 105 | assert not request.is_delete 106 | assert request.is_trace 107 | -------------------------------------------------------------------------------- /docs/source/integrations/django-bird.md: -------------------------------------------------------------------------------- 1 | # django-bird 2 | 3 | >High-flying components for perfectionists with deadlines. 4 | 5 | - 📖 [https://django-bird.readthedocs.io/](https://django-bird.readthedocs.io/) 6 | - 📦 [https://pypi.org/project/django-bird/](https://pypi.org/project/django-bird/) 7 | - 🛠️ [https://github.com/joshuadavidthomas/django-bird](https://github.com/joshuadavidthomas/django-bird) 8 | 9 | ## Installation 10 | 11 | Using the auto settings `django-bird` should not conflict with the two packages. If you would like to configure the library manually here is an example. See [https://django-bird.readthedocs.io/en/latest/configuration.html#manual-setup](https://django-bird.readthedocs.io/en/latest/configuration.html#manual-setup) for more details. 12 | 13 | ```{note} 14 | `django-bird` deprecated its custom template loader in [v0.13.0](https://github.com/joshuadavidthomas/django-bird/releases/tag/v0.13.0), so if you are on an older version you will need to update to use the example config below. 15 | ``` 16 | 17 | ```python 18 | # settings.py 19 | 20 | DJANGO_BIRD = { 21 | "ENABLE_AUTO_CONFIG": False 22 | } # this is optional for `django-bird` 23 | 24 | TEMPLATES = [ 25 | { 26 | "BACKEND": "django.template.backends.django.DjangoTemplates", 27 | "DIRS": [ 28 | BASE_DIR / "templates", # this allows `django-bird` to find components 29 | ], 30 | "OPTIONS": { 31 | "builtins": [ 32 | "django_bird.templatetags.django_bird", # this is not required, but is useful for `django-bird` and is added by the library's auto settings 33 | ], 34 | "context_processors": [ 35 | "django.template.context_processors.debug", 36 | "django.template.context_processors.request", 37 | "django.contrib.auth.context_processors.auth", 38 | "django.contrib.messages.context_processors.messages", 39 | ], 40 | "loaders": [ 41 | ( 42 | "django.template.loaders.cached.Loader", 43 | [ 44 | "dj_angles.template_loader.Loader", 45 | "django.template.loaders.filesystem.Loader", 46 | "django.template.loaders.app_directories.Loader", 47 | ], 48 | ) 49 | ], 50 | }, 51 | }, 52 | ] 53 | ``` 54 | 55 | ## Example 56 | 57 | ```html 58 | 59 | 60 | Click me! 61 | 62 | ``` 63 | 64 | ```html 65 | 66 | 69 | ``` 70 | 71 | ### Default mapper 72 | 73 | Setting [`default_mapper`](../settings.md#default_mapper) provides even tighter integration with `django-bird`. `dj-angles` will use `django-bird` for any tag name that it does not have a mapper for (instead of the default `include` template tag. 74 | 75 | ```python 76 | # settings.py 77 | ANGLES = { 78 | "default_mapper": "dj_angles.mappers.thirdparty.map_bird", 79 | } 80 | ``` 81 | 82 | ```html 83 | 84 | 85 | Click me! 86 | 87 | ``` 88 | 89 | ```html 90 | 91 | 94 | ``` 95 | -------------------------------------------------------------------------------- /tests/dj_angles/templatetags/call/test_do_call.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template.base import TemplateSyntaxError, Token, TokenType 3 | 4 | from dj_angles.evaluator import TemplateVariable 5 | from dj_angles.templatetags.call import do_call 6 | 7 | 8 | def test_function_name(): 9 | token = Token(TokenType.BLOCK, contents="call set_name as name") 10 | actual = do_call(None, token) 11 | 12 | assert actual.parsed_function.portions[0].name == "set_name" 13 | assert actual.parsed_function.portions[0].args == [] 14 | assert actual.parsed_function.portions[0].kwargs == {} 15 | assert actual.context_variable_name == "name" 16 | 17 | 18 | def test_no_args(): 19 | token = Token(TokenType.BLOCK, contents="call") 20 | 21 | with pytest.raises(TemplateSyntaxError) as e: 22 | do_call(None, token) 23 | 24 | assert ( 25 | e.exconly() == "django.template.exceptions.TemplateSyntaxError: call template tag requires at least 1 argument" 26 | ) 27 | 28 | 29 | def test_no_as(): 30 | token = Token(TokenType.BLOCK, contents="call set_name") 31 | actual = do_call(None, token) 32 | 33 | assert actual.parsed_function.portions[0].name == "set_name" 34 | assert actual.parsed_function.portions[0].args == [] 35 | assert actual.parsed_function.portions[0].kwargs == {} 36 | assert actual.context_variable_name is None 37 | 38 | 39 | def test_no_context_variable(): 40 | token = Token(TokenType.BLOCK, contents="call set_name as") 41 | 42 | with pytest.raises(TemplateSyntaxError) as e: 43 | do_call(None, token) 44 | 45 | assert e.exconly() == "django.template.exceptions.TemplateSyntaxError: Missing variable name after 'as'" 46 | 47 | 48 | def test_multiple_context_variables(): 49 | token = Token(TokenType.BLOCK, contents="call set_name as name another") 50 | 51 | with pytest.raises(TemplateSyntaxError) as e: 52 | do_call(None, token) 53 | 54 | assert e.exconly() == "django.template.exceptions.TemplateSyntaxError: Too many arguments after 'as'" 55 | 56 | 57 | def test_str_arg(): 58 | token = Token(TokenType.BLOCK, contents="call set_name('Hello') as name") 59 | actual = do_call(None, token) 60 | 61 | assert actual.parsed_function.portions[0].name == "set_name" 62 | assert actual.parsed_function.portions[0].args == ["Hello"] 63 | assert actual.parsed_function.portions[0].kwargs == {} 64 | assert actual.context_variable_name == "name" 65 | 66 | 67 | def test_multiple_args(): 68 | token = Token(TokenType.BLOCK, contents="call set_name('Hello', 8) as name") 69 | actual = do_call(None, token) 70 | 71 | assert actual.parsed_function.portions[0].name == "set_name" 72 | assert actual.parsed_function.portions[0].args == ["Hello", 8] 73 | assert actual.parsed_function.portions[0].kwargs == {} 74 | assert actual.context_variable_name == "name" 75 | 76 | 77 | def test_template_variable(): 78 | token = Token(TokenType.BLOCK, contents="call set_name(hello, 8) as name") 79 | actual = do_call(None, token) 80 | 81 | portion = actual.parsed_function.portions[0] 82 | 83 | assert portion.name == "set_name" 84 | assert isinstance(portion.args[0], TemplateVariable) 85 | assert portion.args[0].name == "hello" 86 | assert portion.args[1] == 8 87 | assert portion.kwargs == {} 88 | assert actual.context_variable_name == "name" 89 | -------------------------------------------------------------------------------- /docs/source/custom-elements/ajax-form.md: -------------------------------------------------------------------------------- 1 | # ajax-form 2 | 3 | The `ajax-form` is a custom element which wraps around a regular HTML `form` element, but submits the form data via AJAX. This allows the form to be submitted without a page reload, similar to using [HTMX](https://htmx.org/) and the `hx-swap` functionality. 4 | 5 | ```{note} 6 | Make sure to [install `dj_angles` middleware](../installation.md#middleware) to use `is_post` and `is_ajax`. The `dj_angles` scripts must also be [included in the HTML template](../installation.md#scripts). 7 | ``` 8 | 9 | ## Example 10 | 11 | ```python 12 | # views.py 13 | from django.shortcuts import render 14 | from book.models import Book 15 | 16 | def book_detail(request, book_id): 17 | book = Book.objects.filter(id=book_id).first() 18 | 19 | if request.is_post: 20 | book.title = request.POST.get('title') 21 | book.save() 22 | 23 | if not request.is_ajax: 24 | return redirect('book', id=book.id) 25 | 26 | return render(request, 'book.html', {'book': book}) 27 | ``` 28 | 29 | ```html 30 | 31 |
32 | {% csrf_token %} 33 | 34 | 35 | 36 |
37 |
38 | ``` 39 | 40 | ### `form` tag 41 | 42 | There is also a shortcut [`form` tag element](../tag-elements.md#form) when using the `dj-angles` template loader. 43 | 44 | ```html 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | ## Attributes 52 | 53 | ### `swap` 54 | 55 | Defines how the form should be replaced after submission. Valid values are `outerHTML` and `innerHTML`. Defaults to `outerHTML`. 56 | 57 | ```html 58 | 59 |
60 | {% csrf_token %} 61 | 62 | 63 | 64 |
65 |
66 | ``` 67 | 68 | ### `delay` 69 | 70 | Defines the delay in milliseconds before the form is submitted. Defaults to 0. 71 | 72 | ```html 73 | 74 |
75 | {% csrf_token %} 76 | 77 | 78 | 79 |
80 |
81 | ``` 82 | 83 | ## Why not HTMX? 84 | 85 | [`HTMX`](https://htmx.org/) is a helpful, general solution for this use case as well. 86 | 87 | ### HTMX Example 88 | 89 | ```html 90 |
91 | {% csrf_token %} 92 | 93 |
76 |
77 | ``` 78 | 79 | ```html 80 | 81 |
82 | If 83 |
84 |
85 | Elif 86 |
87 |
88 | Else 89 |
90 | ``` 91 | 92 | ## ✨ Inspiration 93 | 94 | - [Web Components](https://web.dev/learn/html/template) 95 | - [Cotton](https://django-cotton.com) by [wrabit](https://github.com/wrabit) 96 | 97 | ```{toctree} 98 | :maxdepth: 2 99 | :hidden: 100 | 101 | self 102 | installation 103 | components 104 | tag-elements 105 | inline-expressions 106 | tag-attributes 107 | examples 108 | ``` 109 | 110 | ```{toctree} 111 | :caption: Filters 112 | :maxdepth: 2 113 | :hidden: 114 | 115 | filters/dateformat 116 | ``` 117 | 118 | ```{toctree} 119 | :caption: Template Tags 120 | :maxdepth: 2 121 | :hidden: 122 | 123 | template-tags/call 124 | template-tags/model 125 | template-tags/template 126 | ``` 127 | 128 | ```{toctree} 129 | :caption: Custom Elements 130 | :maxdepth: 2 131 | :hidden: 132 | 133 | custom-elements/ajax-form 134 | ``` 135 | 136 | ```{toctree} 137 | :caption: Middlewares 138 | :maxdepth: 2 139 | :hidden: 140 | 141 | middlewares/request-method 142 | middlewares/request-ajax 143 | ``` 144 | 145 | ```{toctree} 146 | :caption: Integrations 147 | :maxdepth: 2 148 | :hidden: 149 | 150 | integrations/django-bird 151 | integrations/django-template-partials 152 | ``` 153 | 154 | ```{toctree} 155 | :caption: Advanced 156 | :maxdepth: 2 157 | :hidden: 158 | 159 | settings 160 | mappers 161 | ``` 162 | 163 | ```{toctree} 164 | :caption: API 165 | :maxdepth: 3 166 | :hidden: 167 | 168 | api/dj_angles/index 169 | ``` 170 | 171 | ```{toctree} 172 | :caption: Info 173 | :maxdepth: 2 174 | :hidden: 175 | 176 | changelog 177 | GitHub 178 | Sponsor 179 | ``` 180 | -------------------------------------------------------------------------------- /src/dj_angles/caseconverter/caseconverter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import string 4 | from io import StringIO 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class StringBuffer(StringIO): 10 | """StringBuffer is a wrapper around StringIO. 11 | 12 | By wrapping StringIO, adding debugging information is easy. 13 | """ 14 | 15 | def write(self, s): 16 | super().write(s) 17 | 18 | 19 | DELIMITERS = " -_" 20 | 21 | 22 | def stripable_punctuation(delimiters): 23 | """Construct a string of stripable punctuation based on delimiters. 24 | 25 | Stripable punctuation is defined as all punctuation that is not a delimeter. 26 | """ 27 | 28 | return "".join([c for c in string.punctuation if c not in delimiters]) 29 | 30 | 31 | class CaseConverter: 32 | def __init__(self, s, delimiters=DELIMITERS, strip_punctuation=True): # noqa: FBT002 33 | """Initialize a case conversion. 34 | 35 | On initialization, punctuation can be optionally stripped. If 36 | punctuation is not stripped, it will appear in the output at the 37 | same position as the input. 38 | 39 | BoundaryHandlers should take into consideration whether or not 40 | they are evaluating the first character in a string and whether or 41 | not a character is punctuation. 42 | 43 | Delimeters are taken into consideration when defining stripable 44 | punctuation. 45 | 46 | Delimeters will be reduced to single instances of a delimeter. This 47 | includes transforming ` -_-__ ` to `-`. 48 | 49 | During initialization, the raw input string will be passed through 50 | the prepare_string() method. Child classes should override this 51 | method if they wish to perform pre-conversion checks and manipulate 52 | the string accordingly. 53 | """ 54 | 55 | self._delimiters = delimiters 56 | 57 | s = s.strip(delimiters) 58 | 59 | if strip_punctuation: 60 | punctuation = stripable_punctuation(delimiters) 61 | s = re.sub(f"[{re.escape(punctuation)}]+", "", s) 62 | 63 | # Change recurring delimiters into single delimiters. 64 | s = re.sub(f"[{re.escape(delimiters)}]+", delimiters[0], s) 65 | 66 | self._raw_input = s 67 | self._input_buffer = StringBuffer(self.prepare_string(s)) 68 | self._output_buffer = StringBuffer() 69 | self._boundary_handlers = [] 70 | 71 | self.define_boundaries() 72 | 73 | def add_boundary_handler(self, handler): 74 | """Add a boundary handler.""" 75 | 76 | self._boundary_handlers.append(handler) 77 | 78 | def define_boundaries(self): 79 | """Define boundary handlers. 80 | 81 | define_boundaries() is called when a CaseConverter is initialized. 82 | define_boundaries() should be overridden in a child class to add 83 | boundary handlers. 84 | 85 | A CaseConverter without boundary handlers makes little sense. 86 | """ 87 | 88 | logger.warning("No boundaries defined") 89 | 90 | def delimiters(self): 91 | """Retrieve the delimiters.""" 92 | 93 | return self._delimiters 94 | 95 | def raw(self): 96 | """Retrieve the raw string to be converted.""" 97 | 98 | return self._raw_input 99 | 100 | def init(self, input_buffer, output_buffer): # noqa: ARG002 101 | """Initialize the output buffer. 102 | 103 | Can be overridden. 104 | 105 | See convert() for call order. 106 | """ 107 | 108 | return 109 | 110 | def mutate(self, c): 111 | """Mutate a character not on a boundary. 112 | 113 | Can be overridden. 114 | 115 | See convert() for call order. 116 | """ 117 | 118 | return c 119 | 120 | def prepare_string(self, s: str) -> str: 121 | """Prepare the raw intput string for conversion. 122 | 123 | Executed during CaseConverter initialization providing an opportunity 124 | for child classes to manipulate the string. By default, the string 125 | is not manipulated. 126 | 127 | Can be overridden. 128 | """ 129 | 130 | return s 131 | 132 | def _is_boundary(self, pc, c): 133 | """Determine if we've hit a boundary or not.""" 134 | 135 | for bh in self._boundary_handlers: 136 | if bh.is_boundary(pc, c): 137 | return bh 138 | 139 | return None 140 | 141 | def convert(self) -> str: 142 | """Convert the raw string. 143 | 144 | convert() follows a series of steps. 145 | 146 | 1. Initialize the output buffer using `init()`. 147 | 148 | For every character in the input buffer: 149 | 2. Check if the current position lies on a boundary as defined by the BoundaryHandler instances. 150 | 3. If on a boundary, execute the handler. 151 | 4. Else apply a mutation to the character via `mutate()` and add the mutated character to the output buffer. 152 | """ 153 | 154 | self.init(self._input_buffer, self._output_buffer) 155 | 156 | logger.debug(f"input_buffer = {self._input_buffer.getvalue()}") 157 | 158 | # Previous character (pc) and current character (cc) 159 | pc = None 160 | cc = self._input_buffer.read(1) 161 | 162 | while cc: 163 | bh = self._is_boundary(pc, cc) 164 | if bh: 165 | bh.handle(pc, cc, self._input_buffer, self._output_buffer) 166 | else: 167 | self._output_buffer.write(self.mutate(cc)) 168 | 169 | pc = cc 170 | cc = self._input_buffer.read(1) 171 | 172 | return str(self._output_buffer.getvalue()) 173 | -------------------------------------------------------------------------------- /docs/source/tag-elements.md: -------------------------------------------------------------------------------- 1 | # Tags 2 | 3 | The `dj-angles` approach is shown first and then the equivalent Django Template Language is second. 4 | 5 | ## [`#`](https://docs.djangoproject.com/en/stable/ref/templates/language/#comments) 6 | 7 | ``` 8 | ... 9 | ``` 10 | 11 | ```html 12 | {# ... #} 13 | ``` 14 | 15 | ## [`autoescape-off`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#autoescape) 16 | 17 | ```html 18 | 19 | ... 20 | 21 | ``` 22 | 23 | ```html 24 | {% autoescape off %} 25 | {% endautoescape %} 26 | ``` 27 | 28 | ## [`autoescape-on`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#autoescape) 29 | 30 | ```html 31 | 32 | ... 33 | 34 | ``` 35 | 36 | ```html 37 | {% autoescape on %} 38 | {% endautoescape %} 39 | ``` 40 | 41 | ## [`block`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block) 42 | 43 | ``` 44 | 45 | ... 46 | 47 | ``` 48 | 49 | ```{note} 50 | The end tag can optionally have a name attribute. If it is missing, the `endblock` will use the name attribute from the start tag. 51 | ``` 52 | 53 | ``` 54 | 55 | ... 56 | 57 | ``` 58 | 59 | ```{note} 60 | The tag can be self-closing if there is not default block content. 61 | ``` 62 | 63 | ``` 64 | 65 | ``` 66 | 67 | ```html 68 | {% block content %} 69 | ... 70 | {% endblock content %} 71 | ``` 72 | 73 | ## [`call`](template-tags/call.md) 74 | 75 | ``` 76 | 77 | ``` 78 | 79 | ```html 80 | {% call slugify("Hello Goodbye") as variable_name %} 81 | ``` 82 | 83 | ## [`csrf`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#csrf-token), [`csrf-token`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#csrf-token), [`csrf-input`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#csrf-token) 84 | 85 | ```html 86 | 87 | ``` 88 | 89 | ```html 90 | {% csrf_token %} 91 | ``` 92 | 93 | ## [`comment`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#comment) 94 | 95 | ```html 96 | 97 | ... 98 | 99 | ``` 100 | 101 | ```html 102 | {% comment %} 103 | ... 104 | {% endcomment %} 105 | ``` 106 | 107 | ## `css` 108 | 109 | ``` 110 | 111 | ``` 112 | 113 | ```html 114 | 115 | ``` 116 | 117 | ## [`debug`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#debug) 118 | 119 | ```html 120 | 121 | ``` 122 | 123 | ```html 124 | {% debug %} 125 | ``` 126 | 127 | ## [`extends`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#extends) 128 | 129 | ``` 130 | 131 | 132 | ``` 133 | 134 | ```html 135 | {% extends 'base.html' %} 136 | ``` 137 | 138 | ## [`filter`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#filter) 139 | 140 | ``` 141 | 142 | ``` 143 | 144 | ```html 145 | {% filter ... %} 146 | ``` 147 | 148 | ## [`form`](custom-elements/ajax-form.md) 149 | 150 | ### ajax 151 | 152 | Whether or not the form should be submitted via AJAX. Uses the [`ajax-form` custom element](custom-elements/ajax-form.md). 153 | 154 | ``` 155 | 156 | ... 157 | 158 | ``` 159 | 160 | ```html 161 | 162 | 163 | ... 164 | 165 | 166 | ``` 167 | 168 | #### swap 169 | 170 | Defines how the form should be replaced after submission. Valid values are `outerHTML` and `innerHTML`. 171 | 172 | ``` 173 | 174 | ... 175 | 176 | ``` 177 | 178 | ```html 179 | 180 |
181 | ... 182 |
183 |
184 | ``` 185 | 186 | ### csrf 187 | 188 | Whether or not the form should include a CSRF token. 189 | 190 | ``` 191 | 192 | ... 193 | 194 | ``` 195 | 196 | ```html 197 |
{% csrf_token %} 198 | ... 199 |
200 | ``` 201 | 202 | ## [`lorem`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#lorem) 203 | 204 | ```html 205 | 206 | ``` 207 | 208 | ```html 209 | {% lorem %} 210 | ``` 211 | 212 | ## `image` 213 | 214 | ``` 215 | 216 | ``` 217 | 218 | ```html 219 | 220 | ``` 221 | 222 | ## [`model`](template-tags/model.md) 223 | 224 | ``` 225 | 226 | ``` 227 | 228 | ```html 229 | {% model Book.objects.filter(id=1) as book %} 230 | ``` 231 | 232 | ## [`now`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#now) 233 | 234 | ```html 235 | 236 | ``` 237 | 238 | ```html 239 | {% now %} 240 | ``` 241 | 242 | ## [`spaceless`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#spaceless) 243 | 244 | ```html 245 | 246 | ... 247 | 248 | ``` 249 | 250 | ```html 251 | {% spaceless %} 252 | ... 253 | {% endspaceless %} 254 | ``` 255 | 256 | ## [`templatetag`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#templatetag) 257 | 258 | ``` 259 | 260 | ``` 261 | 262 | ```html 263 | {% templatetag ... %} 264 | ``` 265 | 266 | ## [`verbatim`](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#verbatim) 267 | 268 | ```html 269 | 270 | ... 271 | 272 | ``` 273 | 274 | ```html 275 | {% verbatim %} 276 | ... 277 | {% endverbatim %} 278 | ``` --------------------------------------------------------------------------------