1from __future__ import annotations
2
3import gzip
4import os
5import shutil
6from collections.abc import Iterator
7from pathlib import Path
8
9from plain.runtime import PLAIN_TEMP_PATH
10
11from .finders import Asset, _iter_assets, is_build_output
12from .manifest import AssetsManifest, compute_fingerprint
13
14_SKIP_COMPRESS_EXTENSIONS = (
15 # Images
16 ".jpg",
17 ".jpeg",
18 ".png",
19 ".gif",
20 ".webp",
21 # Compressed files
22 ".zip",
23 ".gz",
24 ".tgz",
25 ".bz2",
26 ".tbz",
27 ".xz",
28 ".br",
29 # Fonts
30 ".woff",
31 ".woff2",
32 # Video
33 ".3gp",
34 ".3gpp",
35 ".asf",
36 ".avi",
37 ".m4v",
38 ".mov",
39 ".mp4",
40 ".mpeg",
41 ".mpg",
42 ".webm",
43 ".wmv",
44)
45
46
47def get_compiled_path() -> Path:
48 """
49 Get the path at runtime to the compiled assets directory.
50
51 There's no reason currently for this to be a user-facing setting.
52 """
53 return PLAIN_TEMP_PATH / "assets" / "compiled"
54
55
56def compile_assets(
57 *, target_dir: str, keep_original: bool, fingerprint: bool, compress: bool
58) -> Iterator[tuple[str, str, list[str]]]:
59 """
60 Compile all assets to the target directory and save a JSON manifest.
61
62 Manifest format:
63 - original path → fingerprinted path (if fingerprinting enabled)
64 - fingerprinted path → None (terminal, no redirect)
65 - original path → None (if no fingerprinting, terminal)
66 """
67 manifest = AssetsManifest()
68
69 for asset in _iter_assets():
70 url_path = asset.url_path
71
72 # Output under dist/ is already content-hashed by the build tool: copy
73 # it as-is (no md5 rename) and serve it immutable at its own name.
74 build_output = is_build_output(url_path)
75
76 fingerprinted_path, compiled_paths = compile_asset(
77 asset=asset,
78 target_dir=target_dir,
79 keep_original=keep_original or build_output,
80 fingerprint=fingerprint and not build_output,
81 compress=compress,
82 )
83
84 if build_output:
85 manifest.add_already_hashed(url_path)
86 resolved_path = url_path
87 elif fingerprinted_path:
88 manifest.add_fingerprinted(url_path, fingerprinted_path)
89 resolved_path = fingerprinted_path
90 else:
91 manifest.add_non_fingerprinted(url_path)
92 resolved_path = url_path
93
94 yield url_path, resolved_path, compiled_paths
95
96 manifest.save()
97
98
99def compile_asset(
100 *,
101 asset: Asset,
102 target_dir: str,
103 keep_original: bool,
104 fingerprint: bool,
105 compress: bool,
106) -> tuple[str | None, list[str]]:
107 """
108 Compile an asset to multiple output paths.
109
110 Returns the fingerprinted URL path (or None) and the list of compiled file paths.
111 """
112 compiled_paths: list[str] = []
113 fingerprinted_url_path: str | None = None
114
115 # The expected destination for the original asset
116 target_path = os.path.join(target_dir, asset.url_path)
117
118 # Make sure all the expected directories exist
119 os.makedirs(os.path.dirname(target_path), exist_ok=True)
120
121 base, extension = os.path.splitext(asset.url_path)
122
123 # Copy the original asset if requested
124 if keep_original:
125 shutil.copy(asset.absolute_path, target_path)
126 compiled_paths.append(target_path)
127
128 # Create fingerprinted version if requested
129 if fingerprint:
130 fingerprint_hash = compute_fingerprint(asset.absolute_path)
131
132 fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
133 fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
134 shutil.copy(asset.absolute_path, fingerprinted_path)
135 compiled_paths.append(fingerprinted_path)
136
137 fingerprinted_url_path = str(os.path.relpath(fingerprinted_path, target_dir))
138
139 if compress and extension.lower() not in _SKIP_COMPRESS_EXTENSIONS:
140 for path in compiled_paths.copy():
141 gzip_path = f"{path}.gz"
142 with gzip.GzipFile(gzip_path, "wb", mtime=0) as f:
143 with open(path, "rb") as f2:
144 f.write(f2.read())
145 compiled_paths.append(gzip_path)
146
147 return fingerprinted_url_path, compiled_paths