Sith/staticfiles/processors.py

201 lines
7.3 KiB
Python
Raw Normal View History

2024-11-20 17:24:28 +00:00
import json
import logging
import subprocess
from dataclasses import dataclass
from hashlib import sha1
2024-11-20 17:24:28 +00:00
from itertools import chain
from pathlib import Path
2024-11-20 17:24:28 +00:00
from typing import Iterable, Self
import rjsmin
import sass
from django.conf import settings
from sith.urls import api
2024-11-20 23:33:40 +00:00
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT
2024-11-20 17:24:28 +00:00
@dataclass
class JsBundlerManifestEntry:
src: str
2024-11-20 23:33:40 +00:00
out: str
2024-11-20 17:24:28 +00:00
@classmethod
def from_json_entry(cls, entry: dict[str, any]) -> list[Self]:
2024-11-20 23:33:40 +00:00
# We have two parts for a manifest entry
# The `src` element which is what the user asks django as a static
# The `out` element which is it's real name in the output static folder
# For the src part:
# The manifest file contains the path of the file relative to the project root
# We want the relative path of the file inside their respective static folder
# because that's what the user types when importing statics and that's what django gives us
# This is really similar to what we are doing in the bundler, it uses a similar algorithm
# Example:
# core/static/bundled/alpine-index.js -> bundled/alpine-index.js
# core/static/bundled/components/include-index.ts -> core/static/bundled/components/include-index.ts
def get_relative_src_name(name: str) -> str:
original_path = Path(name)
relative_path: list[str] = []
for directory in reversed(original_path.parts):
relative_path.append(directory)
# Contrary to the bundler algorithm, we do want to keep the bundled prefix
if directory == BUNDLED_FOLDER_NAME:
break
return str(Path(*reversed(relative_path)))
# For the out part:
# The bundler is configured to output files in generated/bundled and considers this folders as it's root
# Thus, the output name doesn't contain the `bundled` prefix that we need, we add it ourselves
2024-11-20 17:24:28 +00:00
ret = [
cls(
2024-11-20 23:33:40 +00:00
src=get_relative_src_name(entry["src"]),
out=str(Path(BUNDLED_FOLDER_NAME) / entry["file"]),
2024-11-20 17:24:28 +00:00
)
]
2024-11-20 23:33:40 +00:00
def remove_hash(path: Path) -> str:
# Hashes are configured to be surrounded by `.`
# Filenames are like this path/to/file.hash.ext
unhashed = ".".join(path.stem.split(".")[:-1])
return str(path.with_stem(unhashed))
# CSS files generated by entrypoints don't have their own entry in the manifest
# They are however listed as an attribute of the entry point that generates them
# Their listed name is the one that has been generated inside the generated/bundled folder
# We prefix it with `bundled` and then generate an `src` name by removing the hash
2024-11-20 17:24:28 +00:00
for css in entry.get("css", []):
2024-11-20 23:33:40 +00:00
path = Path(BUNDLED_FOLDER_NAME) / css
2024-11-20 17:24:28 +00:00
ret.append(
cls(
2024-11-20 23:33:40 +00:00
src=remove_hash(path),
2024-11-20 17:24:28 +00:00
out=str(path),
)
)
return ret
class JSBundlerManifest:
def __init__(self, manifest: Path):
with open(manifest, "r") as f:
self._manifest = json.load(f)
self._files = chain(
*[
JsBundlerManifestEntry.from_json_entry(value)
for value in self._manifest.values()
if value.get("isEntry", False)
]
)
self.mapping = {file.src: file.out for file in self._files}
class JSBundler:
@staticmethod
def compile():
"""Bundle js files with the javascript bundler for production."""
process = subprocess.Popen(["npm", "run", "compile"])
process.wait()
if process.returncode:
raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
@staticmethod
def runserver() -> subprocess.Popen:
"""Bundle js files automatically in background when called in debug mode."""
logging.getLogger("django").info("Running javascript bundling server")
return subprocess.Popen(["npm", "run", "serve"])
2024-11-20 17:24:28 +00:00
@staticmethod
def get_manifest() -> JSBundlerManifest:
return JSBundlerManifest(BUNDLED_ROOT / ".vite" / "manifest.json")
@staticmethod
def is_in_bundle(name: str | None) -> bool:
if name is None:
return False
2024-11-20 23:33:40 +00:00
return Path(name).parts[0] == BUNDLED_FOLDER_NAME
2024-11-20 17:24:28 +00:00
class Scss:
@dataclass
class CompileArg:
absolute: Path # Absolute path to the file
relative: Path # Relative path inside the folder it has been collected
@staticmethod
def compile(files: CompileArg | Iterable[CompileArg]):
"""Compile scss files to css files."""
# Generate files inside the generated folder
# .css files respects the hierarchy in the static folder it was found
# This converts arg.absolute -> generated/{arg.relative}.scss
# Example:
# app/static/foo.scss -> generated/foo.css
# app/static/bar/foo.scss -> generated/bar/foo.css
# custom/location/bar/foo.scss -> generated/bar/foo.css
if isinstance(files, Scss.CompileArg):
files = [files]
base_args = {"output_style": "compressed", "precision": settings.SASS_PRECISION}
compiled_files = {
file.relative.with_suffix(".css"): sass.compile(
filename=str(file.absolute), **base_args
)
for file in files
}
for file, content in compiled_files.items():
dest = GENERATED_ROOT / file
dest.parent.mkdir(exist_ok=True, parents=True)
dest.write_text(content)
class JS:
@staticmethod
def minify():
to_exec = [
p
for p in settings.STATIC_ROOT.rglob("*.js")
if ".min" not in p.suffixes
2024-11-20 23:33:40 +00:00
and (settings.STATIC_ROOT / BUNDLED_FOLDER_NAME) not in p.parents
]
for path in to_exec:
p = path.resolve()
minified = rjsmin.jsmin(p.read_text())
p.write_text(minified)
logging.getLogger("main").info(f"Minified {path}")
class OpenApi:
OPENAPI_DIR = GENERATED_ROOT / "openapi"
@classmethod
def compile(cls):
"""Compile a TS client for the sith API. Only generates it if it changed."""
logging.getLogger("django").info("Compiling open api typescript client")
out = cls.OPENAPI_DIR / "schema.json"
cls.OPENAPI_DIR.mkdir(parents=True, exist_ok=True)
old_hash = ""
if out.exists():
with open(out, "rb") as f:
old_hash = sha1(f.read()).hexdigest()
schema = api.get_openapi_schema()
# Remove hash from operationIds
# This is done for cache invalidation but this is too aggressive
for path in schema["paths"].values():
for action, desc in path.items():
path[action]["operationId"] = "_".join(
desc["operationId"].split("_")[:-1]
)
schema = str(schema)
if old_hash == sha1(schema.encode("utf-8")).hexdigest():
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
return
with open(out, "w") as f:
_ = f.write(schema)
subprocess.run(["npx", "openapi-ts"], check=True)