Repository: joshuadavidthomas/django-bird

Issue:
Asset tags and registry do not work with more complicated loader setup
In a project using `django-template-partials` for instance, the loaders are not a flat list of loaders, but a tuple with the template partial loader first and then the list of loaders:

```python
[
    (
        "template_partials.loader.Loader",
        [
            "django_bird.loader.BirdLoader",
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    )
]
```

Unfortunately, the `template_loaders` property of django's `Engine` doesn't really work with this structure:

```python
    @cached_property
    def template_loaders(self):
        return self.get_template_loaders(self.loaders)

    def get_template_loaders(self, template_loaders):
        loaders = []
        for template_loader in template_loaders:
            loader = self.find_template_loader(template_loader)
            if loader is not None:
                loaders.append(loader)
        return loaders
```

Given that list above, this method only goes one level deep and just returns `["template_partials.loader.Loader"]` so the `AssetNode.render` method fails:

```
    @override
    def render(self, context: Context) -> str:
        template = context.template
        if template is None:
            return ""
        loaders = template.engine.template_loaders  # here!
        for loader in loaders:
            if isinstance(loader, BirdLoader):
                assets = loader.asset_registry.get_assets(self.asset_type)
                return self._render_assets(assets)

        raise RuntimeError("BirdLoader not found in template loaders")
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18e5e76..b784fc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
 
 - Improved handling of quoted vs unquoted attribute values in `{% bird %}` components. Quoted values (e.g., `class="static-class"`) are treated as literal strings, while unquoted values (e.g., `class=dynamic_class`) are resolved from the template context. This allows for more explicit control over whether an attribute value should be treated as a literal or resolved dynamically.
 
+### Fixed
+
+- **Internal**: Simplified asset management by using a global registry, making it work reliably with any template loader configuration.
+
 ## [0.6.2]
 
 ### Changed
diff --git a/src/django_bird/loader.py b/src/django_bird/loader.py
index 83ed798..18debd6 100644
--- a/src/django_bird/loader.py
+++ b/src/django_bird/loader.py
@@ -17,7 +17,7 @@ from ._typing import _has_nodelist
 from ._typing import override
 from .compiler import Compiler
 from .components import components
-from .staticfiles import ComponentAssetRegistry
+from .staticfiles import assets
 from .templatetags.tags.bird import TAG
 from .templatetags.tags.bird import BirdNode
 
@@ -28,7 +28,6 @@ class BirdLoader(FileSystemLoader):
     def __init__(self, engine: Engine):
         super().__init__(engine)
         self.compiler = Compiler()
-        self.asset_registry = ComponentAssetRegistry()
 
     @override
     def get_contents(self, origin: Origin) -> str:
@@ -52,7 +51,7 @@ class BirdLoader(FileSystemLoader):
     def _scan_for_components(self, node: Template | Node, context: Context) -> None:
         if isinstance(node, BirdNode):
             component = components.get_component(node.name)
-            self.asset_registry.register(component)
+            assets.register(component)
 
         if not _has_nodelist(node) or node.nodelist is None:
             return
@@ -60,7 +59,7 @@ class BirdLoader(FileSystemLoader):
         for child in node.nodelist:
             if isinstance(child, BirdNode):
                 component = components.get_component(child.name)
-                self.asset_registry.register(component)
+                assets.register(component)
 
             if isinstance(child, ExtendsNode):
                 parent_template = child.get_parent(context)
diff --git a/src/django_bird/staticfiles.py b/src/django_bird/staticfiles.py
index 8ebaf3b..4ea9d18 100644
--- a/src/django_bird/staticfiles.py
+++ b/src/django_bird/staticfiles.py
@@ -62,11 +62,18 @@ def get_template_assets(template: DjangoTemplate):
 class ComponentAssetRegistry:
     components: set[Component] = field(default_factory=set)
 
+    def clear(self) -> None:
+        """Clear the component cache. Mainly useful for testing."""
+        self.components.clear()
+
     def register(self, component: Component) -> None:
         self.components.add(component)
 
-    def get_assets(self, asset_type: AssetType) -> set[Asset]:
-        assets: set[Asset] = set()
+    def get_assets(self, asset_type: AssetType) -> list[Asset]:
+        assets: list[Asset] = []
         for component in self.components:
-            assets.update(a for a in component.assets if a.type == asset_type)
+            assets.extend(a for a in component.assets if a.type == asset_type)
         return assets
+
+
+assets = ComponentAssetRegistry()
diff --git a/src/django_bird/templatetags/tags/asset.py b/src/django_bird/templatetags/tags/asset.py
index 5a34914..ce84126 100644
--- a/src/django_bird/templatetags/tags/asset.py
+++ b/src/django_bird/templatetags/tags/asset.py
@@ -8,9 +8,9 @@ from django.template.context import Context
 
 from django_bird._typing import TagBits
 from django_bird._typing import override
-from django_bird.loader import BirdLoader
 from django_bird.staticfiles import Asset
 from django_bird.staticfiles import AssetType
+from django_bird.staticfiles import assets
 
 CSS_TAG = "bird:css"
 JS_TAG = "bird:js"
@@ -48,18 +48,10 @@ class AssetNode(template.Node):
 
     @override
     def render(self, context: Context) -> str:
-        template = context.template
-        if template is None:
-            return ""
-        loaders = template.engine.template_loaders
-        for loader in loaders:
-            if isinstance(loader, BirdLoader):
-                assets = loader.asset_registry.get_assets(self.asset_type)
-                return self._render_assets(assets)
-
-        raise RuntimeError("BirdLoader not found in template loaders")
+        component_assets = assets.get_assets(self.asset_type)
+        return self._render_assets(component_assets)
 
-    def _render_assets(self, assets: set[Asset]) -> str:
+    def _render_assets(self, assets: list[Asset]) -> str:
         if not assets:
             return ""
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index beb308c..446c616 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -164,9 +164,18 @@ def normalize_whitespace():
 
 
 @pytest.fixture(autouse=True)
-def clear_registry():
+def clear_components_registry():
     from django_bird.components import components
 
     components.clear()
     yield
     components.clear()
+
+
+@pytest.fixture(autouse=True)
+def clear_assets_registry():
+    from django_bird.staticfiles import assets
+
+    assets.clear()
+    yield
+    assets.clear()
diff --git a/tests/templatetags/test_asset.py b/tests/templatetags/test_asset.py
index 1720169..167159d 100644
--- a/tests/templatetags/test_asset.py
+++ b/tests/templatetags/test_asset.py
@@ -5,7 +5,6 @@ from django.template.base import Parser
 from django.template.base import Token
 from django.template.base import TokenType
 from django.template.context import Context
-from django.template.engine import Engine
 from django.template.exceptions import TemplateSyntaxError
 
 from django_bird.staticfiles import AssetType
@@ -155,29 +154,6 @@ class TestTemplateTag:
         assert f'<script src="{first_js}"></script>' in rendered[body_start:]
         assert f'<script src="{second_js}"></script>' in rendered[body_start:]
 
-    def test_birdloader_required(self, templates_dir, create_template):
-        template_path = templates_dir / "test.html"
-        template_path.write_text("""
-            <html>
-            <head>
-                {% bird:css %}
-            </head>
-            <body>
-                {% bird:js %}
-            </body>
-            </html>
-        """)
-
-        engine = Engine(
-            builtins=["django_bird.templatetags.django_bird"],
-            dirs=[str(templates_dir)],
-            loaders=["django.template.loaders.filesystem.Loader"],
-        )
-        template = engine.get_template("test.html")
-
-        with pytest.raises(RuntimeError):
-            template.render(Context({}))
-
 
 class TestNode:
     def test_no_template(self):
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 00a117b..e97e79e 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -11,6 +11,7 @@ from django.template.loader import get_template
 from django_bird.loader import BIRD_TAG_PATTERN
 from django_bird.loader import BirdLoader
 from django_bird.params import Params
+from django_bird.staticfiles import assets
 from django_bird.templatetags.tags.bird import BirdNode
 
 
@@ -55,60 +56,6 @@ def test_render_template(template_name):
     assert rendered
 
 
-def test_asset_registry(
-    create_bird_template, create_bird_asset, create_template, templates_dir
-):
-    alert = create_bird_template("alert", '<div class="alert">{{ slot }}</div>')
-    create_bird_asset(alert, ".alert { color: red; }", "css")
-    create_bird_asset(alert, "console.log('alert');", "js")
-
-    badge = create_bird_template("badge", "<span>{{ slot }}</span>")
-    create_bird_asset(badge, ".badge { color: blue; }", "css")
-    create_bird_asset(badge, "console.log('badge');", "js")
-
-    button = create_bird_template("button", "<button>{{ slot }}</button>")
-    create_bird_asset(button, ".button { color: blue; }", "css")
-    create_bird_asset(button, "console.log('button');", "js")
-
-    base_path = templates_dir / "base.html"
-    base_path.write_text("""
-        <html>
-        <head>
-            <title>Test</title>
-            {% bird:css %}
-        </head>
-        <body>
-            {% bird alert %}Base Alert{% endbird %}
-            {% block content %}{% endblock %}
-            {% bird:js %}
-        </body>
-        </html>
-    """)
-
-    include_path = templates_dir / "include.html"
-    include_path.write_text("""
-        {% bird badge %}Active{% endbird %}
-    """)
-
-    child_path = templates_dir / "child.html"
-    child_path.write_text("""
-        {% extends 'base.html' %}
-        {% block content %}
-            {% bird button %}Click me{% endbird %}
-            {% include 'include.html' %}
-        {% endblock %}
-    """)
-
-    template = create_template(child_path)
-
-    engine = template.template.engine
-    loader = engine.template_loaders[0]
-
-    components = loader.asset_registry.components
-
-    assert len(components) == 3
-
-
 @pytest.mark.parametrize(
     "node,expected_count",
     [
@@ -140,4 +87,4 @@ def test_scan_for_components(
 
     loader._scan_for_components(node, context)
 
-    assert len(loader.asset_registry.components) == expected_count
+    assert len(assets.components) == expected_count
diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py
index e418b65..3cdf38c 100644
--- a/tests/test_staticfiles.py
+++ b/tests/test_staticfiles.py
@@ -4,8 +4,10 @@ from pathlib import Path
 
 import pytest
 
+from django_bird.components import components
 from django_bird.staticfiles import Asset
 from django_bird.staticfiles import AssetType
+from django_bird.staticfiles import assets
 from django_bird.staticfiles import get_template_assets
 
 
@@ -119,3 +121,144 @@ class TestGetTemplateAssets:
 
         assert len(assets) == 1
         assert Asset(css_file, AssetType.CSS) in assets
+
+
+class TestComponentAssetRegistry:
+    def test_asset_registry(
+        self, create_bird_template, create_bird_asset, create_template, templates_dir
+    ):
+        alert = create_bird_template("alert", '<div class="alert">{{ slot }}</div>')
+        create_bird_asset(alert, ".alert { color: red; }", "css")
+        create_bird_asset(alert, "console.log('alert');", "js")
+
+        badge = create_bird_template("badge", "<span>{{ slot }}</span>")
+        create_bird_asset(badge, ".badge { color: blue; }", "css")
+        create_bird_asset(badge, "console.log('badge');", "js")
+
+        button = create_bird_template("button", "<button>{{ slot }}</button>")
+        create_bird_asset(button, ".button { color: blue; }", "css")
+        create_bird_asset(button, "console.log('button');", "js")
+
+        base_path = templates_dir / "base.html"
+        base_path.write_text("""
+            <html>
+            <head>
+                <title>Test</title>
+                {% bird:css %}
+            </head>
+            <body>
+                {% bird alert %}Base Alert{% endbird %}
+                {% block content %}{% endblock %}
+                {% bird:js %}
+            </body>
+            </html>
+        """)
+
+        include_path = templates_dir / "include.html"
+        include_path.write_text("""
+            {% bird badge %}Active{% endbird %}
+        """)
+
+        child_path = templates_dir / "child.html"
+        child_path.write_text("""
+            {% extends 'base.html' %}
+            {% block content %}
+                {% bird button %}Click me{% endbird %}
+                {% include 'include.html' %}
+            {% endblock %}
+        """)
+
+        create_template(child_path)
+
+        assert len(assets.components) == 3
+
+    def test_get_assets_by_type(
+        self, create_bird_template, create_bird_asset, create_template
+    ):
+        template_file = create_bird_template("test", "<div>Test</div>")
+        css_asset = create_bird_asset(template_file, ".test { color: red; }", "css")
+        js_asset = create_bird_asset(template_file, "console.log('test');", "js")
+
+        component = components.get_component("test")
+        assets.register(component)
+
+        css_assets = assets.get_assets(AssetType.CSS)
+        js_assets = assets.get_assets(AssetType.JS)
+
+        assert len(css_assets) == 1
+        assert len(js_assets) == 1
+        assert Asset(Path(css_asset), AssetType.CSS) in css_assets
+        assert Asset(Path(js_asset), AssetType.JS) in js_assets
+
+    def test_register_same_component_multiple_times(
+        self, create_bird_template, create_bird_asset
+    ):
+        template_file = create_bird_template("test", "<div>Test</div>")
+        create_bird_asset(template_file, ".test { color: red; }", "css")
+
+        component = components.get_component("test")
+
+        assets.register(component)
+        assets.register(component)
+
+        assert len(assets.components) == 1
+        assert len(assets.get_assets(AssetType.CSS)) == 1
+
+    def test_multiple_components_same_asset_names(
+        self, create_bird_template, create_bird_asset
+    ):
+        template1 = create_bird_template("comp1", "<div>One</div>", sub_dir="first")
+        template2 = create_bird_template("comp2", "<div>Two</div>", sub_dir="second")
+
+        css1 = create_bird_asset(template1, ".one { color: red; }", "css")
+        css2 = create_bird_asset(template2, ".two { color: blue; }", "css")
+
+        comp1 = components.get_component("first/comp1")
+        comp2 = components.get_component("second/comp2")
+
+        assets.register(comp1)
+        assets.register(comp2)
+
+        css_assets = assets.get_assets(AssetType.CSS)
+        assert len(css_assets) == 2
+
+        asset_paths = {str(asset.path) for asset in css_assets}
+        assert str(css1) in asset_paths
+        assert str(css2) in asset_paths
+
+    def test_template_inheritance_assets(
+        self, create_bird_template, create_bird_asset, create_template, templates_dir
+    ):
+        parent = create_bird_template("parent", "<div>Parent</div>")
+        child = create_bird_template("child", "<div>Child</div>")
+
+        parent_css = create_bird_asset(parent, ".parent { color: red; }", "css")
+        child_css = create_bird_asset(child, ".child { color: blue; }", "css")
+
+        base_path = templates_dir / "base.html"
+        base_path.write_text("""
+            {% bird parent %}Parent Content{% endbird %}
+            {% block content %}{% endblock %}
+        """)
+
+        child_path = templates_dir / "child.html"
+        child_path.write_text("""
+            {% extends 'base.html' %}
+            {% block content %}
+                {% bird child %}Child Content{% endbird %}
+            {% endblock %}
+        """)
+
+        template = create_template(child_path)
+        template.render({})
+
+        css_assets = assets.get_assets(AssetType.CSS)
+        asset_paths = {str(asset.path) for asset in css_assets}
+
+        assert str(parent_css) in asset_paths, "Parent CSS not found in assets"
+        assert str(child_css) in asset_paths, "Child CSS not found in assets"
+
+    def test_empty_registry(self):
+        assert len(assets.components) == 0
+        assert len(assets.get_assets(AssetType.CSS)) == 0
+        assert len(assets.get_assets(AssetType.JS)) == 0