Skip to content

Commit 5d73b5f

Browse files
create CSS and JS asset templatetags (#47)
* create `{% bird:css %}` and `{% bird:js %}` tags for handling component assets * use loader to register components in template and use for asset tags * add tests for coverage * swap regex of extends for scanning nodelist * update changelog * update docs
1 parent 4fcf34f commit 5d73b5f

File tree

11 files changed

+570
-21
lines changed

11 files changed

+570
-21
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2020

2121
### Added
2222

23-
- Component assets (CSS/JS) are now automatically discovered and associated with components
23+
- New `{% bird:css %}` and `{% bird:js %}` template tags to automatically include component assets
24+
- Component assets are automatically discovered from matching CSS/JS files next to component templates
2425

2526
### Changed
2627

28+
- **Internal**: Extended `BirdLoader` to track component usage and their assets during template rendering
29+
- **Internal**: Assets are now stored as frozensets for immutability
30+
- **Internal**: Added `ComponentAssetRegistry` to manage component assets during template rendering
2731
- **Internal**: Refactored `AssetType` to use string values and file extensions
2832

2933
### Removed

docs/assets.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# CSS and JavaScript Assets
2+
3+
django-bird automatically discovers and manages CSS and JavaScript assets for your components.
4+
5+
## Asset Discovery
6+
7+
Assets are discovered based on file names matching your component template:
8+
9+
```bash
10+
templates/bird/
11+
├── button.css
12+
├── button.html
13+
└── button.js
14+
```
15+
16+
The library looks for `.css` and `.js` files with the same name as your component template.
17+
18+
You can also organize components in their own directories. The library will find assets as long as they match the component name and are in the same directory as the template:
19+
20+
```bash
21+
templates/bird/button/
22+
├── button.css
23+
├── button.html
24+
└── button.js
25+
```
26+
27+
This organization can be particularly useful when components have multiple related files or when you want to keep component code isolated. See [Template Resolution](naming.md#template-resolution) for more information and [Organization](organization.md) for different ways to structure your components and their assets.
28+
29+
## Using Assets
30+
31+
django-bird provides two templatetags for automatically loading your CSS and Javascript assets into your project's templates:
32+
33+
- `{% bird:css %}`
34+
- `{% bird:js %}`
35+
36+
To include component assets in your templates, add the templatetags to your base template:
37+
38+
```htmldjango
39+
<!DOCTYPE html>
40+
<html>
41+
<head>
42+
{% bird:css %} {# Includes CSS from all components used in template #}
43+
</head>
44+
<body>
45+
{% block content %}{% endblock %}
46+
{% bird:js %} {# Includes JavaScript from all components used in template #}
47+
</body>
48+
</html>
49+
```
50+
51+
The asset tags will automatically:
52+
53+
- Find all components used in the template (including extends and includes)
54+
- Collect their associated assets
55+
- Output the appropriate HTML tags
56+
57+
For example, if your template uses components with associated assets:
58+
59+
```htmldjango
60+
{% bird button %}Click me{% endbird %}
61+
{% bird alert %}Warning!{% endbird %}
62+
```
63+
64+
The asset tags will render:
65+
66+
```html
67+
{# {% bird:css %} renders: #}
68+
<link rel="stylesheet" href="/static/templates/bird/button.css">
69+
<link rel="stylesheet" href="/static/templates/bird/alert.css">
70+
71+
{# {% bird:js %} renders: #}
72+
<script src="/static/templates/bird/button.js"></script>
73+
<script src="/static/templates/bird/alert.js"></script>
74+
```
75+
76+
Assets are automatically deduplicated, so each component's assets are included only once even if the component is used multiple times in your templates. Only assets from components actually used in the template (or its parent templates) will be included - unused components' assets won't be loaded, keeping your pages lean.
77+
78+
## Template Inheritance
79+
80+
Assets are collected from all components used in your template hierarchy:
81+
82+
```{code-block} htmldjango
83+
:caption: base.html
84+
85+
<!DOCTYPE html>
86+
<html>
87+
<head>
88+
{% bird:css %} {# Gets CSS from both nav and content components #}
89+
</head>
90+
<body>
91+
{% bird nav %}{% endbird %}
92+
{% block content %}{% endblock %}
93+
{% bird:js %}
94+
</body>
95+
</html>
96+
```
97+
98+
```{code-block} htmldjango
99+
:caption: page.html
100+
101+
{% extends "base.html" %}
102+
103+
{% block content %}
104+
{% bird content %}
105+
Page content here
106+
{% endbird %}
107+
{% endblock %}
108+
```
109+
110+
The `{% bird:css %}` tag will include CSS and the `[% bird:js %}` tag will include JavaScript from both the `nav` and `content` components.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Naming <naming>
1515
Attributes/Properties <params>
1616
slots
17+
Assets <assets>
1718
Organization <organization>
1819
configuration
1920
```

src/django_bird/components.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Any
66

77
from cachetools import LRUCache
8-
from django.template.backends.django import Template
8+
from django.template.backends.django import Template as DjangoTemplate
99
from django.template.loader import select_template
1010

1111
from django_bird.staticfiles import Asset
@@ -17,8 +17,8 @@
1717
@dataclass(frozen=True, slots=True)
1818
class Component:
1919
name: str
20-
template: Template
21-
assets: set[Asset] = field(default_factory=set)
20+
template: DjangoTemplate
21+
assets: frozenset[Asset] = field(default_factory=frozenset)
2222

2323
def render(self, context: dict[str, Any]):
2424
return self.template.render(context)

src/django_bird/loader.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,70 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import re
45

56
from django.core.cache import cache
7+
from django.template.base import Node
68
from django.template.base import Origin
9+
from django.template.base import Template
10+
from django.template.context import Context
711
from django.template.engine import Engine
12+
from django.template.loader_tags import ExtendsNode
13+
from django.template.loader_tags import IncludeNode
814
from django.template.loaders.filesystem import Loader as FileSystemLoader
915

1016
from ._typing import override
11-
from .compiler import BIRD_PATTERN
1217
from .compiler import Compiler
18+
from .components import components
19+
from .staticfiles import ComponentAssetRegistry
20+
from .templatetags.tags.bird import TAG
21+
from .templatetags.tags.bird import BirdNode
22+
23+
BIRD_TAG_PATTERN = re.compile(rf"{{%\s*{TAG}\s+([^\s%}}]+)")
1324

1425

1526
class BirdLoader(FileSystemLoader):
1627
def __init__(self, engine: Engine):
1728
super().__init__(engine)
1829
self.compiler = Compiler()
30+
self.asset_registry = ComponentAssetRegistry()
1931

2032
@override
2133
def get_contents(self, origin: Origin) -> str:
2234
contents = super().get_contents(origin)
23-
if not BIRD_PATTERN.search(contents):
35+
36+
if not BIRD_TAG_PATTERN.search(contents):
2437
return contents
38+
39+
template = Template(contents, origin=origin, engine=self.engine)
40+
context = Context()
41+
with context.bind_template(template):
42+
self._scan_for_components(template, context)
43+
2544
cache_key = f"bird_component_{hashlib.md5(contents.encode()).hexdigest()}"
26-
compiled = cache.get(cache_key) # pyright: ignore[reportAny]
45+
compiled = cache.get(cache_key)
2746
if compiled is None:
2847
compiled = self.compiler.compile(contents)
2948
cache.set(cache_key, compiled, timeout=None)
3049
return compiled
50+
51+
def _scan_for_components(self, template: Template | Node, context: Context) -> None:
52+
if not hasattr(template, "nodelist"):
53+
return
54+
55+
for node in template.nodelist:
56+
if isinstance(node, BirdNode):
57+
component = components.get_component(node.name)
58+
self.asset_registry.register(component)
59+
60+
if isinstance(node, ExtendsNode):
61+
parent_template = node.get_parent(context)
62+
self._scan_for_components(parent_template, context)
63+
64+
if isinstance(node, IncludeNode):
65+
template_name = node.template.token.strip("'\"")
66+
included_template = self.engine.get_template(template_name)
67+
self._scan_for_components(included_template, context)
68+
69+
if hasattr(node, "nodelist"):
70+
self._scan_for_components(node, context)

src/django_bird/staticfiles.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4+
from dataclasses import field
45
from enum import Enum
56
from pathlib import Path
7+
from typing import TYPE_CHECKING
68

7-
from django.template.backends.django import Template
9+
from django.template.backends.django import Template as DjangoTemplate
810

911
from ._typing import override
1012

13+
if TYPE_CHECKING:
14+
from django_bird.components import Component
15+
1116

1217
class AssetType(Enum):
1318
CSS = "css"
@@ -42,12 +47,26 @@ def from_path(cls, path: Path) -> Asset:
4247
return cls(path=path, type=asset_type)
4348

4449

45-
def get_template_assets(template: Template):
50+
def get_template_assets(template: DjangoTemplate):
4651
assets: set[Asset] = set()
4752
template_path = Path(template.template.origin.name)
4853
for asset_type in AssetType:
4954
asset_path = template_path.with_suffix(asset_type.ext)
5055
if asset_path.exists():
5156
asset = Asset.from_path(asset_path)
5257
assets.add(asset)
53-
return assets
58+
return frozenset(assets)
59+
60+
61+
@dataclass
62+
class ComponentAssetRegistry:
63+
components: set[Component] = field(default_factory=set)
64+
65+
def register(self, component: Component) -> None:
66+
self.components.add(component)
67+
68+
def get_assets(self, asset_type: AssetType) -> set[Asset]:
69+
assets: set[Asset] = set()
70+
for component in self.components:
71+
assets.update(a for a in component.assets if a.type == asset_type)
72+
return assets

src/django_bird/templatetags/django_bird.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
from django import template
44

5+
from .tags import asset
56
from .tags import bird
67
from .tags import prop
78
from .tags import slot
89

910
register = template.Library()
1011

1112

13+
register.tag(asset.CSS_TAG, asset.do_asset)
14+
register.tag(asset.JS_TAG, asset.do_asset)
1215
register.tag(bird.TAG, bird.do_bird)
1316
register.tag(prop.TAG, prop.do_prop)
1417
register.tag(slot.TAG, slot.do_slot)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# pyright: reportAny=false
2+
from __future__ import annotations
3+
4+
from django import template
5+
from django.template.base import Parser
6+
from django.template.base import Token
7+
from django.template.context import Context
8+
9+
from django_bird._typing import TagBits
10+
from django_bird._typing import override
11+
from django_bird.loader import BirdLoader
12+
from django_bird.staticfiles import Asset
13+
from django_bird.staticfiles import AssetType
14+
15+
CSS_TAG = "bird:css"
16+
JS_TAG = "bird:js"
17+
18+
19+
def do_asset(parser: Parser, token: Token) -> AssetNode:
20+
bits = token.split_contents()
21+
asset_type = parse_asset_type(bits)
22+
return AssetNode(asset_type)
23+
24+
25+
def parse_asset_type(bits: TagBits) -> AssetType:
26+
if len(bits) < 1:
27+
msg = "bird:assets tag requires at least one argument"
28+
raise template.TemplateSyntaxError(msg)
29+
30+
tag_name = bits[0]
31+
32+
try:
33+
asset_type = tag_name.split(":")[1]
34+
match asset_type:
35+
case "css":
36+
return AssetType.CSS
37+
case "js":
38+
return AssetType.JS
39+
case _:
40+
raise ValueError(f"Unknown asset type: {asset_type}")
41+
except IndexError as e:
42+
raise ValueError(f"Invalid tag name: {tag_name}") from e
43+
44+
45+
class AssetNode(template.Node):
46+
def __init__(self, asset_type: AssetType):
47+
self.asset_type = asset_type
48+
49+
@override
50+
def render(self, context: Context) -> str:
51+
template = context.template
52+
if template is None:
53+
return ""
54+
loaders = template.engine.template_loaders
55+
for loader in loaders:
56+
if isinstance(loader, BirdLoader):
57+
assets = loader.asset_registry.get_assets(self.asset_type)
58+
return self._render_assets(assets)
59+
60+
raise RuntimeError("BirdLoader not found in template loaders")
61+
62+
def _render_assets(self, assets: set[Asset]) -> str:
63+
if not assets:
64+
return ""
65+
66+
if self.asset_type == AssetType.CSS:
67+
tags = (
68+
f'<link rel="stylesheet" href="{asset.path}">'
69+
for asset in sorted(assets, key=lambda a: a.path)
70+
)
71+
else: # JS
72+
tags = (
73+
f'<script src="{asset.path}"></script>'
74+
for asset in sorted(assets, key=lambda a: a.path)
75+
)
76+
77+
return "\n".join(tags)

0 commit comments

Comments
 (0)