Skip to content

Commit a5a7995

Browse files
Add asset manifest system for better performance (#212)
* Add asset manifest system for performance optimization - Added manifest.py module for loading, generating, and saving asset manifests - Added management command for precomputing template-component relationships - Updated asset tags to use manifest data with runtime fallback - Added automatic detection of development mode for improved DX * Add tests for coverage misses * Simplify asset manifest by using fixed path * remove that * remove comments * fixes * updates * Update docs with asset manifest info * Improve asset manifest docs with collectstatic integration * Sort component names in manifest for consistent output
1 parent 491290b commit a5a7995

File tree

9 files changed

+678
-7
lines changed

9 files changed

+678
-7
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2222

2323
- Added `get_component_names_used_in_template` method to ComponentRegistry to access component names used in a template
2424
- Added `get_component_assets` utility function to staticfiles.py to get assets for a component with optional filtering
25+
- Added asset manifest system for optimized startup and rendering performance
26+
- Added `manifest.py` module for generating, loading, and saving asset manifests
27+
- Added management command `generate_asset_manifest` for pre-computing template-component relationships
28+
- Added automatic fallback to component scanning in development mode (when DEBUG=True)
2529

2630
## [0.16.2]
2731

docs/assets.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,31 @@ python manage.py collectstatic
133133
```
134134

135135
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.
136+
137+
## Asset Manifest
138+
139+
For production deployments, django-bird provides a management command to generate an asset manifest:
140+
141+
```bash
142+
python manage.py generate_asset_manifest
143+
```
144+
145+
This command creates a manifest file at `STATIC_ROOT/django_bird/manifest.json` that maps templates to their used components. In production mode, this manifest is used to load assets without scanning templates at runtime.
146+
147+
### Integration with collectstatic
148+
149+
For optimal deployment, follow this sequence:
150+
151+
1. Run `python manage.py collectstatic` first to collect all component assets
152+
2. Then run `python manage.py generate_asset_manifest` to create the manifest file in the collected static files
153+
154+
This ensures that:
155+
- All component assets are properly collected by the Django staticfiles system
156+
- The manifest is generated with up-to-date component information
157+
- The manifest file is placed in the correct location within your static files directory
158+
159+
For automated deployments, you can combine these commands:
160+
161+
```bash
162+
python manage.py collectstatic --noinput && python manage.py generate_asset_manifest
163+
```

src/django_bird/management/__init__.py

Whitespace-only changes.

src/django_bird/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
from typing import Any
5+
from typing import final
6+
7+
from django.core.management.base import BaseCommand
8+
9+
from django_bird._typing import override
10+
from django_bird.manifest import default_manifest_path
11+
from django_bird.manifest import generate_asset_manifest
12+
from django_bird.manifest import save_asset_manifest
13+
14+
15+
@final
16+
class Command(BaseCommand):
17+
help: str = (
18+
"Generates a manifest of component usage in templates for loading assets"
19+
)
20+
21+
@override
22+
def add_arguments(self, parser: ArgumentParser) -> None:
23+
parser.add_argument(
24+
"--output",
25+
type=str,
26+
default=None,
27+
help="Path where the manifest file should be saved. Defaults to STATIC_ROOT/django_bird/manifest.json",
28+
)
29+
30+
@override
31+
def handle(self, *args: Any, **options: Any) -> None:
32+
manifest_data = generate_asset_manifest()
33+
output_path = options["output"] or default_manifest_path()
34+
save_asset_manifest(manifest_data, output_path)
35+
self.stdout.write(
36+
self.style.SUCCESS(
37+
f"Asset manifest generated successfully at {output_path}"
38+
)
39+
)

src/django_bird/manifest.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
from pathlib import Path
6+
7+
from django.conf import settings
8+
9+
from django_bird.templates import gather_bird_tag_template_usage
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_manifest_cache = None
14+
15+
16+
def load_asset_manifest() -> dict[str, list[str]] | None:
17+
"""Load asset manifest from the default location.
18+
19+
Returns a simple dict mapping template paths to lists of component names.
20+
If the manifest cannot be loaded, returns None and falls back to runtime scanning.
21+
22+
Returns:
23+
dict[str, list[str]] | None: Manifest data or None if not found or invalid
24+
"""
25+
global _manifest_cache
26+
27+
if _manifest_cache is not None:
28+
return _manifest_cache
29+
30+
if hasattr(settings, "STATIC_ROOT") and settings.STATIC_ROOT:
31+
manifest_path = default_manifest_path()
32+
if manifest_path.exists():
33+
try:
34+
with open(manifest_path) as f:
35+
manifest_data = json.load(f)
36+
_manifest_cache = manifest_data
37+
return manifest_data
38+
except json.JSONDecodeError:
39+
logger.warning(
40+
f"Asset manifest at {manifest_path} contains invalid JSON. Falling back to registry."
41+
)
42+
return None
43+
except (OSError, PermissionError) as e:
44+
logger.warning(
45+
f"Error reading asset manifest at {manifest_path}: {str(e)}. Falling back to registry."
46+
)
47+
return None
48+
49+
# No manifest found, will fall back to registry
50+
return None
51+
52+
53+
def generate_asset_manifest() -> dict[str, list[str]]:
54+
"""Generate a manifest by scanning templates for component usage.
55+
56+
Returns:
57+
dict[str, list[str]]: A dictionary mapping template paths to lists of component names.
58+
"""
59+
template_component_map: dict[str, set[str]] = {}
60+
for template_path, component_names in gather_bird_tag_template_usage():
61+
# Convert Path objects to strings for JSON
62+
template_component_map[str(template_path)] = component_names
63+
64+
manifest: dict[str, list[str]] = {
65+
template: sorted(list(components))
66+
for template, components in template_component_map.items()
67+
}
68+
69+
return manifest
70+
71+
72+
def save_asset_manifest(manifest_data: dict[str, list[str]], path: Path | str) -> None:
73+
"""Save asset manifest to a file.
74+
75+
Args:
76+
manifest_data: The manifest data to save
77+
path: Path where to save the manifest
78+
"""
79+
path_obj = Path(path)
80+
path_obj.parent.mkdir(parents=True, exist_ok=True)
81+
82+
with open(path_obj, "w") as f:
83+
json.dump(manifest_data, f, indent=2)
84+
85+
86+
def default_manifest_path() -> Path:
87+
"""Get the default manifest path.
88+
89+
Returns:
90+
Path: The default path for the asset manifest file
91+
"""
92+
if hasattr(settings, "STATIC_ROOT") and settings.STATIC_ROOT:
93+
return Path(settings.STATIC_ROOT) / "django_bird" / "manifest.json"
94+
else:
95+
# Fallback for when STATIC_ROOT is not set
96+
return Path("django_bird-asset-manifest.json")

src/django_bird/templatetags/tags/asset.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
from typing import final
66

77
from django import template
8+
from django.conf import settings
89
from django.template.base import Parser
910
from django.template.base import Token
1011
from django.template.context import Context
1112

1213
from django_bird._typing import override
14+
from django_bird.manifest import load_asset_manifest
1315

1416

1517
class AssetTag(Enum):
@@ -35,18 +37,40 @@ def __init__(self, asset_tag: AssetTag):
3537
@override
3638
def render(self, context: Context) -> str:
3739
from django_bird.components import components
40+
from django_bird.staticfiles import Asset
41+
from django_bird.staticfiles import get_component_assets
3842

3943
template = getattr(context, "template", None)
4044
if not template:
4145
return ""
42-
used_components = components.get_component_usage(template.origin.name)
43-
assets = set(
44-
asset
45-
for component in used_components
46-
for asset in component.assets
47-
if asset.type.tag == self.asset_tag
48-
)
46+
47+
template_path = template.origin.name
48+
49+
used_components = []
50+
51+
# Only use manifest in production mode
52+
if not settings.DEBUG:
53+
manifest = load_asset_manifest()
54+
if manifest and template_path in manifest:
55+
# Use manifest for component names in production
56+
component_names = manifest[template_path]
57+
used_components = [
58+
components.get_component(name) for name in component_names
59+
]
60+
61+
# If we're in development or there was no manifest data, use registry
62+
if not used_components:
63+
used_components = list(components.get_component_usage(template_path))
64+
65+
assets: set[Asset] = set()
66+
for component in used_components:
67+
component_assets = get_component_assets(component)
68+
assets.update(
69+
asset for asset in component_assets if asset.type.tag == self.asset_tag
70+
)
71+
4972
if not assets:
5073
return ""
74+
5175
rendered = [asset.render() for asset in sorted(assets, key=lambda a: a.path)]
5276
return "\n".join(rendered)

0 commit comments

Comments
 (0)