Repository: joshuadavidthomas/django-bird

Issue:
Create staticfiles finder to integrate competent assets with `django.contrib.staticfiles`

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f8d15d..32b712c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
 
 ## [Unreleased]
 
+### Added
+
+- Added `BirdAssetFinder`, a custom staticfiles finder to serve component assets through Django's staticfiles app.
+
+### Deprecated
+
+- The built-in asset serving view (`asset_view`) is deprecated and will be removed in the next minor version (v0.14.0). Use `BirdAssetFinder` with Django's staticfiles app.
+
 ## [0.12.1]
 
 ### Changed
diff --git a/docs/assets.md b/docs/assets.md
index a5ad47e..f88495d 100644
--- a/docs/assets.md
+++ b/docs/assets.md
@@ -65,12 +65,12 @@ 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">
+<link rel="stylesheet" href="/static/django_bird/bird/button.css">
+<link rel="stylesheet" href="/static/django_bird/bird/alert.css">
 
 {# {% bird:js %} renders: #}
-<script src="/static/templates/bird/button.js"></script>
-<script src="/static/templates/bird/alert.js"></script>
+<script src="/static/django_bird/bird/button.js"></script>
+<script src="/static/django_bird/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.
@@ -111,9 +111,36 @@ The `{% bird:css %}` tag will include CSS and the `[% bird:js %}` tag will inclu
 
 ## Serving Assets
 
-### In Development
+### Using the Staticfiles Finder
 
-For development, django-bird includes a built-in view to serve component assets directly through Django. To enable this, add django-bird's URLs to your project's URL configuration:
+django-bird provides a custom staticfiles finder to serve component assets through Django's static files system. This allows you to collect all component assets using Django's `collectstatic` command and serve them efficiently in production.
+
+To enable the custom finder, add `BirdAssetFinder` to your `STATICFILES_FINDERS` setting:
+
+```{code-block} python
+:caption: settings.py
+
+STATICFILES_FINDERS = [
+    # ... your existing finders ...
+    "django_bird.staticfiles.BirdAssetFinder",
+]
+```
+
+After adding the finder, run:
+
+```bash
+python manage.py collectstatic
+```
+
+This will collect all component assets into your static files directory, allowing you to serve them via your web server, [WhiteNoise](https://whitenoise.readthedocs.io), or a CDN.
+
+### Deprecated View-Based Serving
+
+```{warning}
+**Warning:** The built-in asset serving view is deprecated and will be removed in a future release. Please switch to using the custom staticfiles finder and serve assets through Django's static files system.
+```
+
+If you still need to use the view-based asset serving during the transition, you can enable it by adding django-bird's URLs to your project's URL configuration:
 
 ```{code-block} python
 :caption: urls.py
@@ -129,10 +156,4 @@ if settings.DEBUG:
     ]
 ```
 
-This will make component assets available at `/__bird__/assets/<component_name>/<asset_filename>` when `settings.DEBUG` is `True`.
-
-```{warning}
-The built-in asset serving is intended for development only. In production, you should serve assets through your web server or a CDN using Django's static files system.
-
-Future integration with `django.contrib.staticfiles` and the `collectstatic` management command is planned.
-```
+This will make component assets available at `/__bird__/assets/<component_name>/<asset_filename>` when `DEBUG` is `True`.
diff --git a/pyproject.toml b/pyproject.toml
index 534d600..4596332 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,6 +71,8 @@ Source = "https://github.com/joshuadavidthomas/django-bird"
 [tool.basedpyright]
 exclude = ["**/__pycache__"]
 include = ["src"]
+reportAny = false
+reportExplicitAny = false
 
 [[tool.basedpyright.executionEnvironments]]
 root = "src"
diff --git a/src/django_bird/components.py b/src/django_bird/components.py
index e96fd41..47d339a 100644
--- a/src/django_bird/components.py
+++ b/src/django_bird/components.py
@@ -8,14 +8,12 @@ from threading import Lock
 from typing import TYPE_CHECKING
 
 from cachetools import LRUCache
-from django.apps import apps
 from django.conf import settings
 from django.template.backends.django import Template as DjangoTemplate
 from django.template.base import Node
 from django.template.base import NodeList
 from django.template.base import TextNode
 from django.template.context import Context
-from django.template.engine import Engine
 from django.template.exceptions import TemplateDoesNotExist
 from django.template.loader import select_template
 
@@ -28,6 +26,7 @@ from django_bird.templatetags.tags.slot import SlotNode
 
 from .conf import app_settings
 from .staticfiles import AssetType
+from .templates import get_component_directories
 from .templates import get_template_names
 
 if TYPE_CHECKING:
@@ -78,15 +77,16 @@ class Component:
     def from_name(cls, name: str):
         template_names = get_template_names(name)
         template = select_template(template_names)
-
-        assets: set[Asset] = set()
-        template_path = Path(template.template.origin.name)
-        for asset_type in AssetType:
-            asset_path = template_path.with_suffix(asset_type.ext)
-            if asset_path.exists():
-                asset = Asset.from_path(asset_path)
-                assets.add(asset)
-
+        assets: list[Asset] = [
+            asset
+            for asset_type in AssetType
+            if (
+                asset := Asset.from_path(
+                    Path(template.template.origin.name), asset_type
+                )
+            )
+            is not None
+        ]
         return cls(name=name, template=template, assets=frozenset(assets))
 
 
@@ -182,32 +182,8 @@ class ComponentRegistry:
     def __init__(self, maxsize: int = 100):
         self._components: LRUCache[str, Component] = LRUCache(maxsize=maxsize)
 
-    def get_dirs(self):
-        engine = Engine.get_default()
-        dirs: list[str | Path] = list(engine.dirs)
-
-        for app_config in apps.get_app_configs():
-            template_dir = Path(app_config.path) / "templates"
-            if template_dir.is_dir():
-                dirs.append(template_dir)
-
-        base_dir = getattr(settings, "BASE_DIR", None)
-
-        if base_dir is not None:
-            root_template_dir = Path(base_dir) / "templates"
-            if root_template_dir.is_dir():
-                dirs.append(root_template_dir)
-
-        return dirs
-
     def discover_components(self) -> None:
-        template_dirs = self.get_dirs()
-
-        component_dirs = [
-            Path(template_dir) / component_dir
-            for template_dir in template_dirs
-            for component_dir in app_settings.COMPONENT_DIRS + ["bird"]
-        ]
+        component_dirs = get_component_directories()
 
         for component_dir in component_dirs:
             component_dir = Path(component_dir)
@@ -240,13 +216,13 @@ class ComponentRegistry:
         self._components[name] = component
         return component
 
-    def get_assets(self, asset_type: AssetType) -> frozenset[Asset]:
-        assets: set[Asset] = set()
-        for component in self._components.values():
-            for asset in component.assets:
-                if asset.type == asset_type:
-                    assets.add(asset)
-        return frozenset(assets)
+    def get_assets(self, asset_type: AssetType | None = None) -> frozenset[Asset]:
+        return frozenset(
+            asset
+            for component in self._components.values()
+            for asset in component.assets
+            if asset_type is None or asset.type == asset_type
+        )
 
 
 components = ComponentRegistry()
diff --git a/src/django_bird/staticfiles.py b/src/django_bird/staticfiles.py
index 66f5808..9d22eb9 100644
--- a/src/django_bird/staticfiles.py
+++ b/src/django_bird/staticfiles.py
@@ -1,13 +1,28 @@
 from __future__ import annotations
 
+from collections.abc import Iterable
+from collections.abc import Sequence
 from dataclasses import dataclass
 from enum import Enum
 from pathlib import Path
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Literal
+from typing import final
+from typing import overload
 
 from django.contrib.staticfiles import finders
+from django.contrib.staticfiles.finders import BaseFinder
+from django.core.checks import CheckMessage
+from django.core.files.storage import FileSystemStorage
 from django.urls import reverse
 
 from ._typing import override
+from .apps import DjangoBirdAppConfig
+from .templates import get_component_directory_names
+
+if TYPE_CHECKING:
+    pass
 
 
 class AssetType(Enum):
@@ -61,27 +76,117 @@ class Asset:
                 return f'<script src="{self.url}"></script>'
 
     @property
-    def url(self) -> str:
-        component_name = self.path.stem
-        asset_filename = self.path.name
+    def absolute_path(self):
+        return self.path.resolve()
 
-        path = finders.find(f"django_bird/assets/{component_name}/{asset_filename}")
+    @property
+    def relative_path(self):
+        return self.path.relative_to(self.template_dir)
+
+    @property
+    def storage(self):
+        storage = FileSystemStorage(location=str(self.template_dir))
+        storage.prefix = DjangoBirdAppConfig.label  # type: ignore[attr-defined]
+        return storage
 
+    @property
+    def template_dir(self):
+        template_dir = self.path.parent
+        component_dirs = get_component_directory_names()
+        while (
+            len(template_dir.parts) > 1 and template_dir.parts[-1] not in component_dirs
+        ):
+            template_dir = template_dir.parent
+        return template_dir.parent
+
+    @property
+    def url(self) -> str:
+        path = finders.find(str(self.relative_path))
         return path or reverse(
             "django_bird:asset",
             kwargs={
-                "component_name": component_name,
-                "asset_filename": asset_filename,
+                "component_name": self.path.stem,
+                "asset_filename": self.path.name,
             },
         )
 
     @classmethod
-    def from_path(cls, path: Path) -> Asset:
-        match path.suffix.lower():
-            case ".css":
-                asset_type = AssetType.CSS
-            case ".js":
-                asset_type = AssetType.JS
-            case _:
-                raise ValueError(f"Unknown asset type for path: {path}")
-        return cls(path=path, type=asset_type)
+    def from_path(cls, path: Path, asset_type: AssetType):
+        asset_path = path.with_suffix(asset_type.ext)
+        if asset_path.exists():
+            return cls(path=asset_path, type=asset_type)
+        return None
+
+
+@final
+class BirdAssetFinder(BaseFinder):
+    def __init__(
+        self, app_names: Sequence[str] | None = None, *args: Any, **kwargs: Any
+    ) -> None:
+        from .components import components
+
+        self.components = components
+        super().__init__(*args, **kwargs)
+
+    @override
+    def check(self, **kwargs: Any) -> list[CheckMessage]:
+        return []
+
+    # Django 5.2 changed the argument from `find` to `find_all`, but django-stubs
+    # (as of the time of this commit) hasn't been updated to reflect this, hence the
+    # type ignore
+    @overload  # type: ignore[override]
+    def find(self, path: str, *, all: Literal[False] = False) -> str | None: ...
+    @overload
+    def find(self, path: str, *, all: Literal[True]) -> list[str]: ...
+    @overload
+    def find(self, path: str, *, find_all: Literal[False] = False) -> str | None: ...
+    @overload
+    def find(self, path: str, *, find_all: Literal[True]) -> list[str]: ...
+    @override
+    def find(
+        self,
+        path: str,
+        all: bool = False,
+        find_all: bool | None = None,
+    ) -> str | list[str] | None:
+        """
+        Given a relative file path, return the absolute path(s) where it can be found.
+        """
+        if find_all is None:
+            find_all = all
+
+        self.components.discover_components()
+
+        matches: list[str] = []
+        path_obj = Path(path)
+
+        for asset in self.components.get_assets():
+            if path_obj == asset.relative_path:
+                matched_path = str(asset.absolute_path)
+            elif asset.relative_path.is_relative_to(path_obj):
+                matched_path = str(path_obj.resolve())
+            else:
+                continue
+
+            if not find_all:
+                return matched_path
+            matches.append(matched_path)
+
+        return matches
+
+    @override
+    def list(
+        self, ignore_patterns: Iterable[str] | None
+    ) -> Iterable[tuple[str, FileSystemStorage]]:
+        """
+        Return (relative_path, storage) pairs for all assets.
+        """
+        self.components.discover_components()
+
+        for asset in self.components.get_assets():
+            if ignore_patterns and any(
+                asset.relative_path.match(pattern) for pattern in set(ignore_patterns)
+            ):
+                continue
+            yield str(asset.relative_path), asset.storage
diff --git a/src/django_bird/templates.py b/src/django_bird/templates.py
index d7a8234..38e911e 100644
--- a/src/django_bird/templates.py
+++ b/src/django_bird/templates.py
@@ -1,8 +1,18 @@
 from __future__ import annotations
 
+from pathlib import Path
+
+from django.apps import apps
+from django.conf import settings
+from django.template.engine import Engine
+
 from .conf import app_settings
 
 
+def get_component_directory_names():
+    return list(dict.fromkeys([*app_settings.COMPONENT_DIRS, "bird"]))
+
+
 def get_template_names(name: str) -> list[str]:
     """
     Generate a list of potential template names for a component.
@@ -39,7 +49,7 @@ def get_template_names(name: str) -> list[str]:
         list[str]: A list of potential template names in resolution order.
     """
     template_names = []
-    component_dirs = list(dict.fromkeys([*app_settings.COMPONENT_DIRS, "bird"]))
+    component_dirs = get_component_directory_names()
 
     name_parts = name.split(".")
     path_name = "/".join(name_parts)
@@ -54,3 +64,26 @@ def get_template_names(name: str) -> list[str]:
         template_names.extend(potential_names)
 
     return list(dict.fromkeys(template_names))
+
+
+def get_component_directories():
+    engine = Engine.get_default()
+    template_dirs: list[str | Path] = list(engine.dirs)
+
+    for app_config in apps.get_app_configs():
+        template_dir = Path(app_config.path) / "templates"
+        if template_dir.is_dir():
+            template_dirs.append(template_dir)
+
+    base_dir = getattr(settings, "BASE_DIR", None)
+
+    if base_dir is not None:
+        root_template_dir = Path(base_dir) / "templates"
+        if root_template_dir.is_dir():
+            template_dirs.append(root_template_dir)
+
+    return [
+        Path(template_dir) / component_dir
+        for template_dir in template_dirs
+        for component_dir in get_component_directory_names()
+    ]
diff --git a/src/django_bird/templatetags/tags/asset.py b/src/django_bird/templatetags/tags/asset.py
index 7031d4c..7123115 100644
--- a/src/django_bird/templatetags/tags/asset.py
+++ b/src/django_bird/templatetags/tags/asset.py
@@ -36,7 +36,5 @@ class AssetNode(template.Node):
         assets = components.get_assets(self.asset_type)
         if not assets:
             return ""
-        rendered = frozenset(
-            asset.render() for asset in sorted(assets, key=lambda a: a.path)
-        )
+        rendered = [asset.render() for asset in sorted(assets, key=lambda a: a.path)]
         return "\n".join(rendered)
diff --git a/src/django_bird/urls.py b/src/django_bird/urls.py
index cbff569..2834845 100644
--- a/src/django_bird/urls.py
+++ b/src/django_bird/urls.py
@@ -2,9 +2,10 @@ from __future__ import annotations
 
 from django.urls import path
 
+from .apps import DjangoBirdAppConfig
 from .views import asset_view
 
-app_name = "django_bird"
+app_name = DjangoBirdAppConfig.label
 
 urlpatterns = [
     path("assets/<str:component_name>/<str:asset_filename>", asset_view, name="asset"),
diff --git a/src/django_bird/views.py b/src/django_bird/views.py
index 6a2b722..f24df21 100644
--- a/src/django_bird/views.py
+++ b/src/django_bird/views.py
@@ -13,6 +13,13 @@ from .components import components
 
 
 def asset_view(request: HttpRequest, component_name: str, asset_filename: str):
+    warnings.warn(
+        "The 'asset_view' is deprecated and will be removed in a future release. "
+        "Use the 'BirdAssetFinder' with Django's static files system instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+
     if not settings.DEBUG:
         warnings.warn(
             "Serving assets through this view in production is not recommended.",


Test patch:
diff --git a/tests/test_components.py b/tests/test_components.py
index 9ac23da..a425a00 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -456,13 +456,11 @@ class TestComponentRegistryAssets:
         button = TestComponent(
             name="button", content="<button>Click me</button>"
         ).create(templates_dir)
-
         button_css = TestAsset(
             component=button,
             content=".button { color: red; }",
             asset_type=AssetType.CSS,
         ).create()
-
         button_js = TestAsset(
             component=button,
             content="console.log('loaded');",
@@ -479,6 +477,29 @@ class TestComponentRegistryAssets:
         assert Asset(button_css.file, button_css.asset_type) in css_assets
         assert Asset(button_js.file, button_js.asset_type) in js_assets
 
+    def test_get_assets_all(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: red; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        button_js = TestAsset(
+            component=button,
+            content="console.log('loaded');",
+            asset_type=AssetType.JS,
+        ).create()
+
+        components.discover_components()
+
+        assets = components.get_assets()
+
+        assert len(assets) == 2
+        assert Asset(button_css.file, button_css.asset_type) in assets
+        assert Asset(button_js.file, button_js.asset_type) in assets
+
     def test_assets_from_multiple_components(self, templates_dir):
         button1 = TestComponent(name="button1", content="<button>One</button>").create(
             templates_dir
diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py
index 6a4380e..739840f 100644
--- a/tests/test_staticfiles.py
+++ b/tests/test_staticfiles.py
@@ -1,14 +1,26 @@
 from __future__ import annotations
 
+import shutil
 from pathlib import Path
 
 import pytest
+from django.conf import settings
+from django.contrib.staticfiles import finders
+from django.core.management import call_command
+from django.template.base import Template
+from django.template.context import Context
+from django.test import override_settings
 
+from django_bird.components import Component
 from django_bird.staticfiles import Asset
 from django_bird.staticfiles import AssetType
+from django_bird.staticfiles import BirdAssetFinder
 
+from .utils import TestAsset
+from .utils import TestComponent
 
-class TestAsset:
+
+class TestAssetClass:
     def test_hash(self):
         asset1 = Asset(Path("static.css"), AssetType.CSS)
         asset2 = Asset(Path("static.css"), AssetType.CSS)
@@ -30,6 +42,7 @@ class TestAsset:
 
     def test_exists_nonexistent(self):
         missing_asset = Asset(Path("missing.css"), AssetType.CSS)
+
         assert missing_asset.exists() is False
 
     @pytest.mark.parametrize(
@@ -41,29 +54,543 @@ class TestAsset:
     )
     def test_render(self, asset, expected_html_tag_bits):
         rendered = asset.render()
+
         assert expected_html_tag_bits in rendered
         assert asset.url in rendered
 
-    @pytest.mark.parametrize(
-        "path,expected",
-        [
-            (Path("static.css"), Asset(Path("static.css"), AssetType.CSS)),
-            (Path("static.js"), Asset(Path("static.js"), AssetType.JS)),
-            (
-                Path("nested/path/style.css"),
-                Asset(Path("nested/path/style.css"), AssetType.CSS),
-            ),
-            (
-                Path("./relative/script.js"),
-                Asset(Path("./relative/script.js"), AssetType.JS),
-            ),
-            (Path("UPPERCASE.CSS"), Asset(Path("UPPERCASE.CSS"), AssetType.CSS)),
-            (Path("mixed.Js"), Asset(Path("mixed.Js"), AssetType.JS)),
-        ],
-    )
-    def test_from_path(self, path, expected):
-        assert Asset.from_path(path) == expected
+    def test_storage(self, templates_dir, settings):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(button.name)
+        asset = component.get_asset(button_css.file.name)
+
+        assert str(asset.storage.location) in str(button_css.file)
+
+    def test_template_dir(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(button.name)
+        asset = component.get_asset(button_css.file.name)
+
+        assert asset.template_dir == templates_dir
+
+    def test_template_dir_nested(self, templates_dir):
+        button = TestComponent(
+            name="button",
+            content="<button>Click me</button>",
+            sub_dir="nested",
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(f"{button.sub_dir}.{button.name}")
+        asset = component.get_asset(button_css.file.name)
+
+        assert asset.template_dir == templates_dir
+
+    def test_relative_path(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(button.name)
+        asset = component.get_asset(button_css.file.name)
+
+        assert asset.relative_path == Path("bird/button.css")
+
+    def test_relative_path_nested(self, templates_dir):
+        button = TestComponent(
+            name="button",
+            content="<button>Click me</button>",
+            sub_dir="nested",
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(f"{button.sub_dir}.{button.name}")
+        asset = component.get_asset(button_css.file.name)
+
+        assert asset.relative_path == Path("bird/nested/button.css")
+
+    def test_url_with_staticfiles_finder(self, templates_dir):
+        button = TestComponent(
+            name="button",
+            content="<button>Click me</button>",
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(button.name)
+        asset = component.get_asset(button_css.file.name)
+
+        with override_settings(
+            STATICFILES_FINDERS=settings.STATICFILES_FINDERS
+            + ["django_bird.staticfiles.BirdAssetFinder"],
+        ):
+            assert asset.url == str(button_css.file)
+
+    def test_url_with_reverse_fallback(self, templates_dir):
+        button = TestComponent(
+            name="button",
+            content="<button>Click me</button>",
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        component = Component.from_name(button.name)
+        asset = component.get_asset(button_css.file.name)
+
+        assert asset.url == f"/__bird__/assets/{component.name}/{asset.path.name}"
+
+    def test_from_path(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        button_js = TestAsset(
+            component=button, content="console.log('button');", asset_type=AssetType.JS
+        ).create()
+
+        assert Asset.from_path(button.file, button_css.asset_type) == Asset(
+            button_css.file, button_css.asset_type
+        )
+        assert Asset.from_path(button.file, button_js.asset_type) == Asset(
+            button_js.file, button_js.asset_type
+        )
+
+    def test_from_template_nested(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>", sub_dir="nested"
+        ).create(templates_dir)
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        assert Asset.from_path(button.file, button_css.asset_type) == Asset(
+            button_css.file, button_css.asset_type
+        )
+
+
+class TestBirdAssetFinder:
+    def test_check(self):
+        finder = BirdAssetFinder()
+
+        assert finder.check() == []
+
+    def test_list_all_assets(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        input = TestComponent(name="input", content="<input>").create(templates_dir)
+
+        assets = [
+            TestAsset(
+                component=button,
+                content=".button { color: blue; }",
+                asset_type=AssetType.CSS,
+            ).create(),
+            TestAsset(
+                component=button,
+                content="console.log('button');",
+                asset_type=AssetType.JS,
+            ).create(),
+            TestAsset(
+                component=input,
+                content="input { border: 1px solid black; }",
+                asset_type=AssetType.CSS,
+            ).create(),
+            TestAsset(
+                component=input,
+                content="console.log('input');",
+                asset_type=AssetType.JS,
+            ).create(),
+        ]
+
+        finder = BirdAssetFinder()
+        listed_assets = list(finder.list(None))
+
+        assert len(listed_assets) == len(assets)
+
+        for asset in assets:
+            assert any(
+                asset.file.name in relative_path for relative_path, _ in listed_assets
+            )
+
+    def test_list_ignore(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        finder = BirdAssetFinder()
+
+        assert len(list(finder.list(["bird/*"]))) == 0
+        assert len(list(finder.list(["*/button*"]))) == 0
+        assert len(list(finder.list(["*.css"]))) == 0
+
+    def test_list_custom_dir(self, templates_dir, override_app_settings):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        custom_button = TestComponent(
+            name="button", content="<button>Click me</button>", parent_dir="components"
+        ).create(templates_dir)
+
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        custom_button_css = TestAsset(
+            component=custom_button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        finder = BirdAssetFinder()
+
+        with override_app_settings(COMPONENT_DIRS=["components"]):
+            listed_assets = list(finder.list(None))
+
+        assert len(listed_assets) == 1
+        # components are matched like templates, dirs in
+        # `settings.DJANGO_BIRD["COMPONENT_DIRS"]` take precedence
+        assert listed_assets[0][0] in str(custom_button_css.file)
+        assert listed_assets[0][0] not in str(button_css.file)
+
+
+class TestFindersFind:
+    @pytest.fixture(autouse=True)
+    def setup_finder(self):
+        with override_settings(
+            STATICFILES_FINDERS=settings.STATICFILES_FINDERS
+            + ["django_bird.staticfiles.BirdAssetFinder"],
+        ):
+            yield
+
+    def test_find_asset(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        button_js = TestAsset(
+            component=button, content="console.log('button');", asset_type=AssetType.JS
+        ).create()
+
+        assert finders.find("bird/button.css") == str(button_css.file)
+        assert finders.find("bird/button.js") == str(button_js.file)
+
+    def test_find_asset_custom_dir(self, templates_dir, override_app_settings):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        custom_button = TestComponent(
+            name="button", content="<button>Click me</button>", parent_dir="components"
+        ).create(templates_dir)
+
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        custom_button_css = TestAsset(
+            component=custom_button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        with override_app_settings(COMPONENT_DIRS=["components"]):
+            # components are matched like templates, dirs in
+            # `settings.DJANGO_BIRD["COMPONENT_DIRS"]` take precedence
+            assert finders.find("components/button.css") == str(custom_button_css.file)
+            assert finders.find("bird/button.css") is None
+
+    def test_find_nested_asset(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>", sub_dir="nested"
+        ).create(templates_dir)
+
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        assert finders.find("bird/nested/button.css") == str(button_css.file)
+
+    def test_case_sensitive_component_names(self, templates_dir):
+        component = TestComponent(name="Button", content="<button>").create(
+            templates_dir
+        )
+        TestAsset(component=component, content="", asset_type=AssetType.CSS).create()
+
+        assert finders.find("bird/Button.css") is not None
+        assert finders.find("bird/button.css") is None
+
+    def test_find_intermediate_paths(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>", sub_dir="nested"
+        ).create(templates_dir)
+
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        assert finders.find("bird/") is not None
+        assert finders.find("bird/nested/") is not None
+        assert finders.find("bird/nested/button.css") is not None
+
+    def test_find_component_template(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        assert finders.find("bird/button.html") is None
+
+    def test_find_nonexistent(self):
+        assert finders.find("bird/nonexistent.js") is None
+
+    def test_find_all(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+
+        button_css = TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        assert finders.find("bird/button.css", all=True) == [str(button_css.file)]
+
+
+class TestStaticCollection:
+    @pytest.fixture(autouse=True)
+    def staticfiles_app(self):
+        with override_settings(
+            INSTALLED_APPS=settings.INSTALLED_APPS + ["django.contrib.staticfiles"],
+            STATIC_URL="/static/",
+            STATICFILES_FINDERS=settings.STATICFILES_FINDERS
+            + ["django_bird.staticfiles.BirdAssetFinder"],
+        ):
+            yield
+
+    @pytest.fixture
+    def static_root(self, tmp_path):
+        static_dir = tmp_path / "static"
+        static_dir.mkdir()
+
+        with override_settings(STATIC_ROOT=str(static_dir)):
+            yield static_dir
+
+        shutil.rmtree(static_dir)
+
+    def test_basic(self, templates_dir, static_root):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        TestAsset(
+            component=button,
+            content="console.log('button');",
+            asset_type=AssetType.JS,
+        ).create()
+
+        call_command("collectstatic", interactive=False, verbosity=0)
+
+        assert (static_root / "django_bird/bird/button.css").exists()
+        assert (static_root / "django_bird/bird/button.js").exists()
+
+    def test_custom_dir(self, templates_dir, static_root, override_app_settings):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        custom_button = TestComponent(
+            name="button", content="<button>Click me</button>", parent_dir="components"
+        ).create(templates_dir)
+
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        TestAsset(
+            component=custom_button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        with override_app_settings(COMPONENT_DIRS=["components"]):
+            call_command("collectstatic", interactive=False, verbosity=0)
+
+            # components are matched like templates, dirs in
+            # `settings.DJANGO_BIRD["COMPONENT_DIRS"]` take precedence
+            assert (static_root / "django_bird/components/button.css").exists()
+            assert not (static_root / "django_bird/bird/button.css").exists()
+
+    def test_same_named_assets(self, templates_dir, static_root):
+        button1 = TestComponent(
+            name="button", content="<button>Form Button</button>", sub_dir="forms"
+        ).create(templates_dir)
+        button2 = TestComponent(
+            name="button", content="<button>Nav Button</button>", sub_dir="nav"
+        ).create(templates_dir)
+
+        button1_css = TestAsset(
+            component=button1,
+            content=".form-button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+        button2_css = TestAsset(
+            component=button2,
+            content=".nav-button { color: red; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        call_command("collectstatic", interactive=False, verbosity=0)
+
+        form_css = static_root / "django_bird/bird/forms/button.css"
+        nav_css = static_root / "django_bird/bird/nav/button.css"
+
+        assert form_css.exists()
+        assert nav_css.exists()
+        assert form_css.read_text() == button1_css.content
+        assert nav_css.read_text() == button2_css.content
+        assert form_css.read_text() != button2_css.content
+        assert nav_css.read_text() != button1_css.content
+
+    def test_dry_run_collection(self, templates_dir, static_root):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        call_command("collectstatic", interactive=False, dry_run=True, verbosity=0)
+
+        assert not (static_root / "bird").exists()
+
+    def test_clear_collection(self, templates_dir, static_root):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        call_command("collectstatic", interactive=False, verbosity=0)
+
+        assert (static_root / "django_bird/bird/button.css").exists()
+        assert not (static_root / "django_bird/bird/input.css").exists()
+
+        input = TestComponent(name="input", content="<input>").create(templates_dir)
+        TestAsset(
+            component=input,
+            content="input { border: 1px solid black; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        call_command("collectstatic", interactive=False, clear=True, verbosity=0)
+
+        assert (static_root / "django_bird/bird/button.css").exists()
+        assert (static_root / "django_bird/bird/input.css").exists()
+
+
+class TestStaticTemplateTag:
+    @pytest.fixture(autouse=True)
+    def staticfiles_app(self):
+        with override_settings(
+            INSTALLED_APPS=settings.INSTALLED_APPS + ["django.contrib.staticfiles"],
+            STATIC_URL="/static/",
+            STATICFILES_FINDERS=settings.STATICFILES_FINDERS
+            + ["django_bird.staticfiles.BirdAssetFinder"],
+        ):
+            yield
+
+    def test_static_tag(self, templates_dir):
+        button = TestComponent(
+            name="button", content="<button>Click me</button>"
+        ).create(templates_dir)
+        TestAsset(
+            component=button,
+            content=".button { color: blue; }",
+            asset_type=AssetType.CSS,
+        ).create()
+
+        template = Template(
+            '{% load static %}{% static "django_bird/bird/button.css" %}'
+        )
+        rendered = template.render(Context({}))
+
+        assert rendered == "/static/django_bird/bird/button.css"
+
+    def test_static_tag_nonexistent_asset(self):
+        template = Template(
+            '{% load static %}{% static "django_bird/bird/nonexistent.css" %}'
+        )
+        rendered = template.render(Context({}))
 
-    def test_from_path_invalid(self):
-        with pytest.raises(ValueError):
-            Asset.from_path(Path("static.html"))
+        assert rendered == "/static/django_bird/bird/nonexistent.css"
diff --git a/tests/test_views.py b/tests/test_views.py
index 78d7ce2..8cb130d 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -11,6 +11,12 @@ from django_bird.staticfiles import AssetType
 from .utils import TestAsset
 from .utils import TestComponent
 
+pytestmark = [
+    pytest.mark.filterwarnings(
+        "ignore:The 'asset_view' is deprecated.*:DeprecationWarning"
+    ),
+]
+
 
 @pytest.fixture(autouse=True)
 def debug_mode():
diff --git a/tests/utils.py b/tests/utils.py
index fd10ea7..533f6a4 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -69,3 +69,14 @@ class TestComponentCase:
     expected: str
     description: str = ""
     template_context: dict[str, Any] = field(default_factory=dict)
+
+
+def print_directory_tree(root_dir: str | Path, prefix: str = ""):
+    root_path = Path(root_dir)
+    contents = sorted(root_path.iterdir())
+    pointers = ["├── "] * (len(contents) - 1) + ["└── "]
+    for pointer, path in zip(pointers, contents):
+        print(prefix + pointer + path.name)
+        if path.is_dir():
+            extension = "│   " if pointer == "├── " else "    "
+            print_directory_tree(path, prefix=prefix + extension)