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