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 | bool]):
13 """
14 A manifest of compiled assets. Each path's value encodes its role:
15
16 - a ``str`` → redirect to that served URL (an original → its fingerprinted name)
17 - ``True`` → an immutable terminal (served at its own name, cache forever)
18 - ``False`` → a mutable terminal (served at its own name, short cache)
19
20 Immutability is stored inline, not derived — so it survives save/load, and a
21 really large manifest stays compact (one bare flag per terminal, no second
22 structure). Paths not in the manifest were not compiled.
23 """
24
25 def __init__(self):
26 self.path = PLAIN_TEMP_PATH / "assets" / "manifest.json"
27
28 def load(self) -> None:
29 if not self.path.exists():
30 return
31 with open(self.path) as f:
32 self.update(json.load(f))
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 Plain-fingerprinted asset: the original redirects to the immutable hashed name."""
40 self[original_path] = fingerprinted_path
41 self[fingerprinted_path] = True
42
43 def add_non_fingerprinted(self, path: str) -> None:
44 """Add a mutable terminal — served at its own name, not cached forever."""
45 self[path] = False
46
47 def add_already_hashed(self, path: str) -> None:
48 """Add an already content-hashed asset: an immutable terminal whose hash the
49 build tool owns, so Plain serves it as-is (no md5 rename)."""
50 self[path] = True
51
52 def is_immutable(self, path: str) -> bool:
53 """Whether the path is served with far-future immutable caching."""
54 return self.get(path) is True
55
56 def resolve(self, url_path: str) -> str | None:
57 """Resolve an asset URL path to its served path.
58
59 Returns the redirect target for an original, the path itself for a
60 terminal, or None if the asset was not compiled.
61 """
62 if url_path not in self:
63 return None
64 target = self[url_path]
65 return target if isinstance(target, str) else url_path
66
67
68@cache
69def get_manifest() -> AssetsManifest:
70 """
71 A cached function for loading the assets manifest,
72 so we don't have to keep loading it from disk over and over.
73 """
74 manifest = AssetsManifest()
75 manifest.load()
76 return manifest
77
78
79def compute_fingerprint(file_path: str) -> str:
80 """Compute an MD5-based fingerprint hash for a file."""
81 with open(file_path, "rb") as f:
82 content = f.read()
83
84 return hashlib.md5(content, usedforsecurity=False).hexdigest()[:_FINGERPRINT_LENGTH]