1from __future__ import annotations
2
3import hashlib
4import json
5from functools import cache
6
7from plain.runtime import PLAIN_TEMP_PATH
8
9_FINGERPRINT_LENGTH = 7
10
11
12class AssetsManifest(dict[str, str | None]):
13 """
14 A manifest of compiled assets.
15
16 Keys are all compiled asset paths. Values are either:
17 - A string path to redirect to (original → fingerprinted)
18 - None if this is the final path (no redirect needed)
19
20 Assets not in the manifest were not compiled.
21 """
22
23 def __init__(self):
24 self.path = PLAIN_TEMP_PATH / "assets" / "manifest.json"
25 self.fingerprinted_paths: set[str] = set()
26
27 def load(self) -> None:
28 if self.path.exists():
29 with open(self.path) as f:
30 self.update(json.load(f))
31 # Build set of fingerprinted paths (redirect targets from original → fingerprinted mappings)
32 self.fingerprinted_paths = {v for v in self.values() if v is not None}
33
34 def save(self) -> None:
35 with open(self.path, "w") as f:
36 json.dump(self, f, indent=2)
37
38 def add_fingerprinted(self, original_path: str, fingerprinted_path: str) -> None:
39 """Add a fingerprinted asset.
40
41 Creates two entries:
42 - original_path -> fingerprinted_path (redirect)
43 - fingerprinted_path -> None (terminal)
44 """
45 self[original_path] = fingerprinted_path
46 self[fingerprinted_path] = None
47 self.fingerprinted_paths.add(fingerprinted_path)
48
49 def add_non_fingerprinted(self, path: str) -> None:
50 """Add a non-fingerprinted asset (terminal, no redirect)."""
51 self[path] = None
52
53 def is_fingerprinted(self, path: str) -> bool:
54 """Check if a path is a fingerprinted path (pointed to by another entry)."""
55 return path in self.fingerprinted_paths
56
57 def resolve(self, url_path: str) -> str | None:
58 """
59 Get the best compiled path for an asset URL path.
60
61 Returns the redirect target if one exists, otherwise the path itself if compiled.
62 Returns None if the asset is not in the manifest (was not compiled).
63 """
64 if url_path not in self:
65 return None
66 # If there's a redirect target, use it; otherwise use the path itself
67 return self[url_path] or url_path
68
69
70@cache
71def get_manifest() -> AssetsManifest:
72 """
73 A cached function for loading the assets manifest,
74 so we don't have to keep loading it from disk over and over.
75 """
76 manifest = AssetsManifest()
77 manifest.load()
78 return manifest
79
80
81def compute_fingerprint(file_path: str) -> str:
82 """Compute an MD5-based fingerprint hash for a file."""
83 with open(file_path, "rb") as f:
84 content = f.read()
85
86 return hashlib.md5(content, usedforsecurity=False).hexdigest()[:_FINGERPRINT_LENGTH]