v0.152.0
 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]