Repository: joshuadavidthomas/django-bird

Issue:
Collect CSS and JS static assets
Any CSS or JS defined in a bird component should be collected as part of the compilation and thrown in some registry that can then be referenced to provide assets when a component is included in a template.

I really like [Flux](https://fluxui.dev) here, where there's just one reference per asset type in the base template:

```
<head>
    ...
    @fluxStyles
</head>

<body>
    ...
    @fluxScripts
</body>
```

I assume that's laravel's version of a templatetag, so for django-bird the equivalent would look something like:

```htmldjango
{% load django_bird %}

<head>
	{% django_bird_css %}
</head>
<body>
	{% django_bird_js %}
</body>
```

(Not married to those templatetag names, but it'll do for now.)

Then in the templatetag's themselves, have someway to look up what components are on the page and only return those. This is a wishlist item, as I sit here now I'm struggling to come up with how to handle partials e.g. through an HTMX request.

There may be a way to accomplish this, but in the mean time just returning all the component asset files will be fine. 

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2f57a0..545a052 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,10 +20,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
 
 ### Added
 
-- Component assets (CSS/JS) are now automatically discovered and associated with components
+- New `{% bird:css %}` and `{% bird:js %}` template tags to automatically include component assets
+- Component assets are automatically discovered from matching CSS/JS files next to component templates
 
 ### Changed
 
+- **Internal**: Extended `BirdLoader` to track component usage and their assets during template rendering
+- **Internal**: Assets are now stored as frozensets for immutability
+- **Internal**: Added `ComponentAssetRegistry` to manage component assets during template rendering
 - **Internal**: Refactored `AssetType` to use string values and file extensions
 
 ### Removed
diff --git a/docs/assets.md b/docs/assets.md
new file mode 100644
index 0000000..644742f
--- /dev/null
+++ b/docs/assets.md
@@ -0,0 +1,110 @@
+# CSS and JavaScript Assets
+
+django-bird automatically discovers and manages CSS and JavaScript assets for your components.
+
+## Asset Discovery
+
+Assets are discovered based on file names matching your component template:
+
+```bash
+templates/bird/
+├── button.css
+├── button.html
+└── button.js
+```
+
+The library looks for `.css` and `.js` files with the same name as your component template.
+
+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:
+
+```bash
+templates/bird/button/
+├── button.css
+├── button.html
+└── button.js
+```
+
+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.
+
+## Using Assets
+
+django-bird provides two templatetags for automatically loading your CSS and Javascript assets into your project's templates:
+
+- `{% bird:css %}`
+- `{% bird:js %}`
+
+To include component assets in your templates, add the templatetags to your base template:
+
+```htmldjango
+<!DOCTYPE html>
+<html>
+    <head>
+        {% bird:css %}  {# Includes CSS from all components used in template #}
+    </head>
+    <body>
+        {% block content %}{% endblock %}
+        {% bird:js %}   {# Includes JavaScript from all components used in template #}
+    </body>
+</html>
+```
+
+The asset tags will automatically:
+
+- Find all components used in the template (including extends and includes)
+- Collect their associated assets
+- Output the appropriate HTML tags
+
+For example, if your template uses components with associated assets:
+
+```htmldjango
+{% bird button %}Click me{% endbird %}
+{% bird alert %}Warning!{% endbird %}
+```
+
+The asset tags will render:
+
+```html
+{# {% bird:css %} renders: #}
+<link rel="stylesheet" href="/static/templates/bird/button.css">
+<link rel="stylesheet" href="/static/templates/bird/alert.css">
+
+{# {% bird:js %} renders: #}
+<script src="/static/templates/bird/button.js"></script>
+<script src="/static/templates/bird/alert.js"></script>
+```
+
+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.
+
+## Template Inheritance
+
+Assets are collected from all components used in your template hierarchy:
+
+```{code-block} htmldjango
+:caption: base.html
+
+<!DOCTYPE html>
+<html>
+    <head>
+        {% bird:css %}  {# Gets CSS from both nav and content components #}
+    </head>
+    <body>
+        {% bird nav %}{% endbird %}
+        {% block content %}{% endblock %}
+        {% bird:js %}
+    </body>
+</html>
+```
+
+```{code-block} htmldjango
+:caption: page.html
+
+{% extends "base.html" %}
+
+{% block content %}
+    {% bird content %}
+        Page content here
+    {% endbird %}
+{% endblock %}
+```
+
+The `{% bird:css %}` tag will include CSS and the `[% bird:js %}` tag will include JavaScript from both the `nav` and `content` components.
diff --git a/docs/index.md b/docs/index.md
index 7a4a1ee..16c3200 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -14,6 +14,7 @@
 Naming <naming>
 Attributes/Properties <params>
 slots
+Assets <assets>
 Organization <organization>
 configuration
 ```
diff --git a/src/django_bird/components.py b/src/django_bird/components.py
index 4b752ec..9188235 100644
--- a/src/django_bird/components.py
+++ b/src/django_bird/components.py
@@ -5,7 +5,7 @@ from dataclasses import field
 from typing import Any
 
 from cachetools import LRUCache
-from django.template.backends.django import Template
+from django.template.backends.django import Template as DjangoTemplate
 from django.template.loader import select_template
 
 from django_bird.staticfiles import Asset
@@ -17,8 +17,8 @@ from .templates import get_template_names
 @dataclass(frozen=True, slots=True)
 class Component:
     name: str
-    template: Template
-    assets: set[Asset] = field(default_factory=set)
+    template: DjangoTemplate
+    assets: frozenset[Asset] = field(default_factory=frozenset)
 
     def render(self, context: dict[str, Any]):
         return self.template.render(context)
diff --git a/src/django_bird/loader.py b/src/django_bird/loader.py
index 6c08dc2..d895983 100644
--- a/src/django_bird/loader.py
+++ b/src/django_bird/loader.py
@@ -1,30 +1,70 @@
 from __future__ import annotations
 
 import hashlib
+import re
 
 from django.core.cache import cache
+from django.template.base import Node
 from django.template.base import Origin
+from django.template.base import Template
+from django.template.context import Context
 from django.template.engine import Engine
+from django.template.loader_tags import ExtendsNode
+from django.template.loader_tags import IncludeNode
 from django.template.loaders.filesystem import Loader as FileSystemLoader
 
 from ._typing import override
-from .compiler import BIRD_PATTERN
 from .compiler import Compiler
+from .components import components
+from .staticfiles import ComponentAssetRegistry
+from .templatetags.tags.bird import TAG
+from .templatetags.tags.bird import BirdNode
+
+BIRD_TAG_PATTERN = re.compile(rf"{{%\s*{TAG}\s+([^\s%}}]+)")
 
 
 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:
         contents = super().get_contents(origin)
-        if not BIRD_PATTERN.search(contents):
+
+        if not BIRD_TAG_PATTERN.search(contents):
             return contents
+
+        template = Template(contents, origin=origin, engine=self.engine)
+        context = Context()
+        with context.bind_template(template):
+            self._scan_for_components(template, context)
+
         cache_key = f"bird_component_{hashlib.md5(contents.encode()).hexdigest()}"
-        compiled = cache.get(cache_key)  # pyright: ignore[reportAny]
+        compiled = cache.get(cache_key)
         if compiled is None:
             compiled = self.compiler.compile(contents)
             cache.set(cache_key, compiled, timeout=None)
         return compiled
+
+    def _scan_for_components(self, template: Template | Node, context: Context) -> None:
+        if not hasattr(template, "nodelist"):
+            return
+
+        for node in template.nodelist:
+            if isinstance(node, BirdNode):
+                component = components.get_component(node.name)
+                self.asset_registry.register(component)
+
+            if isinstance(node, ExtendsNode):
+                parent_template = node.get_parent(context)
+                self._scan_for_components(parent_template, context)
+
+            if isinstance(node, IncludeNode):
+                template_name = node.template.token.strip("'\"")
+                included_template = self.engine.get_template(template_name)
+                self._scan_for_components(included_template, context)
+
+            if hasattr(node, "nodelist"):
+                self._scan_for_components(node, context)
diff --git a/src/django_bird/staticfiles.py b/src/django_bird/staticfiles.py
index 1b94648..8ebaf3b 100644
--- a/src/django_bird/staticfiles.py
+++ b/src/django_bird/staticfiles.py
@@ -1,13 +1,18 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+from dataclasses import field
 from enum import Enum
 from pathlib import Path
+from typing import TYPE_CHECKING
 
-from django.template.backends.django import Template
+from django.template.backends.django import Template as DjangoTemplate
 
 from ._typing import override
 
+if TYPE_CHECKING:
+    from django_bird.components import Component
+
 
 class AssetType(Enum):
     CSS = "css"
@@ -42,7 +47,7 @@ class Asset:
         return cls(path=path, type=asset_type)
 
 
-def get_template_assets(template: Template):
+def get_template_assets(template: DjangoTemplate):
     assets: set[Asset] = set()
     template_path = Path(template.template.origin.name)
     for asset_type in AssetType:
@@ -50,4 +55,18 @@ def get_template_assets(template: Template):
         if asset_path.exists():
             asset = Asset.from_path(asset_path)
             assets.add(asset)
-    return assets
+    return frozenset(assets)
+
+
+@dataclass
+class ComponentAssetRegistry:
+    components: set[Component] = field(default_factory=set)
+
+    def register(self, component: Component) -> None:
+        self.components.add(component)
+
+    def get_assets(self, asset_type: AssetType) -> set[Asset]:
+        assets: set[Asset] = set()
+        for component in self.components:
+            assets.update(a for a in component.assets if a.type == asset_type)
+        return assets
diff --git a/src/django_bird/templatetags/django_bird.py b/src/django_bird/templatetags/django_bird.py
index 678694f..937f086 100644
--- a/src/django_bird/templatetags/django_bird.py
+++ b/src/django_bird/templatetags/django_bird.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from django import template
 
+from .tags import asset
 from .tags import bird
 from .tags import prop
 from .tags import slot
@@ -9,6 +10,8 @@ from .tags import slot
 register = template.Library()
 
 
+register.tag(asset.CSS_TAG, asset.do_asset)
+register.tag(asset.JS_TAG, asset.do_asset)
 register.tag(bird.TAG, bird.do_bird)
 register.tag(prop.TAG, prop.do_prop)
 register.tag(slot.TAG, slot.do_slot)
diff --git a/src/django_bird/templatetags/tags/asset.py b/src/django_bird/templatetags/tags/asset.py
new file mode 100644
index 0000000..5a34914
--- /dev/null
+++ b/src/django_bird/templatetags/tags/asset.py
@@ -0,0 +1,77 @@
+# pyright: reportAny=false
+from __future__ import annotations
+
+from django import template
+from django.template.base import Parser
+from django.template.base import Token
+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
+
+CSS_TAG = "bird:css"
+JS_TAG = "bird:js"
+
+
+def do_asset(parser: Parser, token: Token) -> AssetNode:
+    bits = token.split_contents()
+    asset_type = parse_asset_type(bits)
+    return AssetNode(asset_type)
+
+
+def parse_asset_type(bits: TagBits) -> AssetType:
+    if len(bits) < 1:
+        msg = "bird:assets tag requires at least one argument"
+        raise template.TemplateSyntaxError(msg)
+
+    tag_name = bits[0]
+
+    try:
+        asset_type = tag_name.split(":")[1]
+        match asset_type:
+            case "css":
+                return AssetType.CSS
+            case "js":
+                return AssetType.JS
+            case _:
+                raise ValueError(f"Unknown asset type: {asset_type}")
+    except IndexError as e:
+        raise ValueError(f"Invalid tag name: {tag_name}") from e
+
+
+class AssetNode(template.Node):
+    def __init__(self, asset_type: AssetType):
+        self.asset_type = asset_type
+
+    @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")
+
+    def _render_assets(self, assets: set[Asset]) -> str:
+        if not assets:
+            return ""
+
+        if self.asset_type == AssetType.CSS:
+            tags = (
+                f'<link rel="stylesheet" href="{asset.path}">'
+                for asset in sorted(assets, key=lambda a: a.path)
+            )
+        else:  # JS
+            tags = (
+                f'<script src="{asset.path}"></script>'
+                for asset in sorted(assets, key=lambda a: a.path)
+            )
+
+        return "\n".join(tags)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index afd6a3a..beb308c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,7 @@ from pathlib import Path
 
 import pytest
 from django.conf import settings
+from django.template.backends.django import DjangoTemplates
 from django.template.backends.django import Template as DjangoTemplate
 from django.template.engine import Engine
 from django.test import override_settings
@@ -48,21 +49,21 @@ TEST_SETTINGS = {
 
 
 @pytest.fixture
-def base_dir(tmp_path):
-    base_dir = tmp_path / "templates"
-    base_dir.mkdir()
-    return base_dir
+def templates_dir(tmp_path):
+    templates_dir = tmp_path / "templates"
+    templates_dir.mkdir()
+    return templates_dir
 
 
 @pytest.fixture(autouse=True)
-def override_templates_settings(base_dir):
+def override_templates_settings(templates_dir):
     with override_settings(
         TEMPLATES=[
             settings.TEMPLATES[0]
             | {
                 "DIRS": [
                     *settings.TEMPLATES[0]["DIRS"],
-                    base_dir,
+                    templates_dir,
                 ]
             }
         ]
@@ -71,9 +72,9 @@ def override_templates_settings(base_dir):
 
 
 @pytest.fixture
-def create_bird_dir(base_dir):
+def create_bird_dir(templates_dir):
     def func(name):
-        bird_template_dir = base_dir / name
+        bird_template_dir = templates_dir / name
         bird_template_dir.mkdir(exist_ok=True)
         return bird_template_dir
 
@@ -96,12 +97,42 @@ def create_bird_template(create_bird_dir):
     return func
 
 
+@pytest.fixture
+def create_bird_asset():
+    def func(component_template: Path, content: str, asset_type: str):
+        component_dir = component_template.parent
+        component_name = component_template.stem
+        asset_file = component_dir / f"{component_name}.{asset_type}"
+        asset_file.write_text(content)
+        return asset_file
+
+    return func
+
+
 @pytest.fixture
 def create_template():
     def _create_template(template_file: Path) -> DjangoTemplate:
-        engine = Engine.get_default()
-        django_template = engine.get_template(str(template_file))
-        return DjangoTemplate(django_template, engine)
+        engine = Engine(
+            builtins=["django_bird.templatetags.django_bird"],
+            dirs=[str(template_file.parent)],
+            loaders=["django_bird.loader.BirdLoader"],
+        )
+        print(f"Engine dirs: {engine.dirs}")  # Debug print
+        print(f"Looking for template: {template_file.name}")  # Debug print
+        template = engine.get_template(template_file.name)
+        backend = DjangoTemplates(
+            {
+                "NAME": "django",
+                "DIRS": [],
+                "APP_DIRS": True,
+                "OPTIONS": {
+                    "autoescape": True,
+                    "debug": False,
+                    "context_processors": [],
+                },
+            }
+        )
+        return DjangoTemplate(template, backend)
 
     return _create_template
 
diff --git a/tests/templatetags/test_asset.py b/tests/templatetags/test_asset.py
new file mode 100644
index 0000000..1720169
--- /dev/null
+++ b/tests/templatetags/test_asset.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+import pytest
+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
+from django_bird.templatetags.tags.asset import CSS_TAG
+from django_bird.templatetags.tags.asset import JS_TAG
+from django_bird.templatetags.tags.asset import AssetNode
+from django_bird.templatetags.tags.asset import do_asset
+
+
+class TestTemplateTag:
+    @pytest.mark.parametrize(
+        "tag,expected",
+        [
+            (CSS_TAG, AssetType.CSS),
+            (JS_TAG, AssetType.JS),
+        ],
+    )
+    def test_asset_type(self, tag, expected):
+        token = Token(TokenType.BLOCK, tag)
+        parser = Parser([])
+        node = do_asset(parser, token)
+        assert node.asset_type == expected
+
+    def test_missing_tag_name(self):
+        token = Token(TokenType.BLOCK, "")
+        parser = Parser([])
+        with pytest.raises(TemplateSyntaxError):
+            do_asset(parser, token)
+
+    @pytest.mark.parametrize(
+        "tag",
+        ["bird:jsx", "birdcss"],
+    )
+    def test_invalid_tag_name(self, tag):
+        token = Token(TokenType.BLOCK, tag)
+        parser = Parser([])
+        with pytest.raises(ValueError):
+            do_asset(parser, token)
+
+    def test_template_inheritence(
+        self, create_bird_template, create_bird_asset, create_template, templates_dir
+    ):
+        alert = create_bird_template("alert", '<div class="alert">{{ slot }}</div>')
+        alert_css = create_bird_asset(alert, ".alert { color: red; }", "css")
+        alert_js = create_bird_asset(alert, "console.log('alert');", "js")
+
+        button = create_bird_template("button", "<button>{{ slot }}</button>")
+        button_css = create_bird_asset(button, ".button { color: blue; }", "css")
+        button_js = 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>
+        """)
+
+        child_path = templates_dir / "child.html"
+        child_path.write_text("""
+            {% extends 'base.html' %}
+            {% block content %}
+                {% bird button %}Click me{% endbird %}
+            {% endblock %}
+        """)
+
+        template = create_template(child_path)
+
+        rendered = template.render({})
+
+        assert f'<link rel="stylesheet" href="{alert_css}">' in rendered
+        assert f'<link rel="stylesheet" href="{button_css}">' in rendered
+        assert f'<script src="{alert_js}"></script>' in rendered
+        assert f'<script src="{button_js}"></script>' in rendered
+
+    def test_with_no_assets(
+        self, create_bird_template, create_bird_asset, create_template, templates_dir
+    ):
+        create_bird_template("alert", '<div class="alert">{{ slot }}</div>')
+
+        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>
+        """)
+
+        template = create_template(base_path)
+
+        rendered = template.render({})
+
+        assert '<link rel="stylesheet" href="' not in rendered
+        assert '<script src="' not in rendered
+
+    def test_component_render_order(
+        self, create_bird_template, create_bird_asset, create_template, templates_dir
+    ):
+        first = create_bird_template("first", "<div>First: {{ slot }}</div>")
+        first_css = create_bird_asset(first, ".first { color: red; }", "css")
+        first_js = create_bird_asset(first, "console.log('first');", "js")
+
+        second = create_bird_template("second", "<div>Second: {{ slot }}</div>")
+        second_css = create_bird_asset(second, ".second { color: red; }", "css")
+        second_js = create_bird_asset(second, "console.log('second');", "js")
+
+        template_path = templates_dir / "test.html"
+        template_path.write_text("""
+            <html>
+            <head>
+                {% bird:css %}
+            </head>
+            <body>
+                <div>
+                    {% bird first %}One{% endbird %}
+                    <p>Some content</p>
+                    {% bird second %}Two{% endbird %}
+                </div>
+                {% bird:js %}
+            </body>
+            </html>
+        """)
+
+        template = create_template(template_path)
+
+        rendered = template.render({})
+
+        head_end = rendered.find("</head>")
+        assert f'<link rel="stylesheet" href="{first_css}">' in rendered[:head_end]
+        assert f'<link rel="stylesheet" href="{second_css}">' in rendered[:head_end]
+
+        body_start = rendered.find("<body")
+        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):
+        node = AssetNode(AssetType.CSS)
+        context = Context({})
+        assert node.render(context) == ""
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 2d2c68b..577dd40 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -3,6 +3,30 @@ from __future__ import annotations
 import pytest
 from django.template.loader import get_template
 
+from django_bird.loader import BIRD_TAG_PATTERN
+
+
+@pytest.mark.parametrize(
+    "template_content,expected_matches",
+    [
+        ("{% bird button %}", ["button"]),
+        ("{% bird alert %}{% bird button %}", ["alert", "button"]),
+        ("{% bird 'button' %}", ["button"]),
+        ('{% bird "button" %}', ["button"]),
+        ("{%bird button%}", ["button"]),
+        ("{%    bird    button    %}", ["button"]),
+        ("{% endbird button %}", []),
+        ("{% birds button %}", []),
+        ("<bird:button>", []),
+        ("{% extends 'base.html' %}{% bird button %}", ["button"]),
+    ],
+)
+def test_bird_tag_pattern(template_content, expected_matches):
+    matches = [
+        m.group(1).strip("'\"") for m in BIRD_TAG_PATTERN.finditer(template_content)
+    ]
+    assert matches == expected_matches
+
 
 @pytest.mark.parametrize(
     "template_name",
@@ -21,3 +45,57 @@ def test_render_template(template_name):
     rendered = template.render(context)
 
     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