#!/usr/bin/env python3 # build-modlist.py — generate landing/src/data/mods.json (+ extracted logos) # from the distribution's full client modset (offline, tomllib + zipfile only). # # Mirrors classify-mods.py: enumerates the distribution's forgemods/required # jars, reads each jar's META-INF/neoforge.mods.toml, takes the PRIMARY [[mods]] # entry, and emits a flat, alphabetically-sorted catalogue for the landing # mod-list component (no categories, no external links). Each mod's logoFile PNG # (if declared and present in the jar) is extracted to # landing/public/mod-logos/.png. No network calls. Pure stdlib (Python # 3.11+ for tomllib). # # Source dir resolution (first match wins) — identical to classify-mods.py: # 1. CLI arg: build-modlist.py # 2. env MODS_DIST_DIR # 3. $DISTRIBUTION_WEB_ROOT from .env / environment # The path may point at the dist ROOT or directly at the # …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted. import glob import json import os import sys import zipfile try: import tomllib except ModuleNotFoundError: sys.exit("build-modlist.py requires Python 3.11+ (tomllib is stdlib)") REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) LANDING = os.path.join(REPO_ROOT, "landing") MODS_JSON = os.path.join(LANDING, "src", "data", "mods.json") LOGOS_DIR = os.path.join(LANDING, "public", "mod-logos") REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required") MANIFEST = "META-INF/neoforge.mods.toml" SCHEMA_VERSION = 1 def read_env_dist_root(): """Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env.""" val = os.environ.get("DISTRIBUTION_WEB_ROOT") if val: return val env_path = os.path.join(REPO_ROOT, ".env") if os.path.isfile(env_path): with open(env_path, encoding="utf-8") as fh: for line in fh: line = line.strip() if line.startswith("DISTRIBUTION_WEB_ROOT="): return line.split("=", 1)[1].strip().strip('"').strip("'") return None def resolve_required_dir(): """Resolve the forgemods/required dir from override or .env. Halt if missing.""" src = None origin = None if len(sys.argv) > 1 and sys.argv[1]: src, origin = sys.argv[1], "CLI arg" elif os.environ.get("MODS_DIST_DIR"): src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env" else: src = read_env_dist_root() origin = "$DISTRIBUTION_WEB_ROOT" if not src: sys.exit( "no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env" ) candidates = [os.path.join(src, REQUIRED_SUBPATH), src] for cand in candidates: if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required": return cand, origin for cand in candidates: if os.path.isdir(cand): return cand, origin sys.exit( f"forgemods/required dir not found (from {origin}={src!r}). " f"Tried: {', '.join(candidates)}" ) def stringify_authors(value): """authors may be a string or a list; normalise to a comma-joined string.""" if value is None: return "" if isinstance(value, str): return value.strip() if isinstance(value, list): parts = [str(v).strip() for v in value if str(v).strip()] return ", ".join(parts) return str(value).strip() def resolve_version(version, manifest): """Resolve a trivial ${file.jarVersion} placeholder; else leave as-is. NeoForge substitutes ${file.jarVersion} from the jar's MANIFEST.MF at load time. We don't have that here, so only resolve when there's nothing better; leave other ${...} placeholders untouched rather than guess. """ if not isinstance(version, str): return str(version) if version is not None else "" v = version.strip() if v in ("${file.jarVersion}", "${global.mcVersion}"): # Can't resolve from the toml alone — surface empty, caller falls back. return "" return v def primary_mod_entry(manifest): """Return the first [[mods]] table, or None.""" mods = manifest.get("mods") if isinstance(mods, list) and mods and isinstance(mods[0], dict): return mods[0] return None def extract_logo(zf, manifest, mod, modid): """Extract the logoFile PNG to LOGOS_DIR/.png. Return public path or None. logoFile may be declared per-[[mods]] or as a top-level key. If absent, or the named entry isn't in the jar, return None (logo: null). """ logo_file = None if isinstance(mod, dict) and mod.get("logoFile"): logo_file = mod["logoFile"] elif manifest.get("logoFile"): logo_file = manifest["logoFile"] if not logo_file or not isinstance(logo_file, str): return None logo_file = logo_file.strip().lstrip("/") if not logo_file: return None # Logos in NeoForge jars live at the resource root (e.g. "icon.png" or # "assets//icon.png"). Try the declared path, then a couple of common # fallbacks, all by exact zip member name. candidates = [logo_file] if "/" not in logo_file: candidates.append(f"assets/{modid}/{logo_file}") member = None names = set(zf.namelist()) for cand in candidates: if cand in names: member = cand break if member is None: return None try: data = zf.read(member) except (KeyError, zipfile.BadZipFile, OSError): return None if not data: return None out_path = os.path.join(LOGOS_DIR, f"{modid}.png") with open(out_path, "wb") as fh: fh.write(data) return f"/mod-logos/{modid}.png" def read_jar(jar_path): """Parse one jar → mod dict, or None to skip (with a warning printed).""" name = os.path.basename(jar_path) try: with zipfile.ZipFile(jar_path) as zf: try: raw = zf.read(MANIFEST) except KeyError: print(f" skip (no {MANIFEST}): {name}", file=sys.stderr) return None try: manifest = tomllib.loads(raw.decode("utf-8")) except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: print(f" skip (bad toml: {exc}): {name}", file=sys.stderr) return None mod = primary_mod_entry(manifest) if mod is None or not mod.get("modId"): print(f" skip (no [[mods]] / modId): {name}", file=sys.stderr) return None modid = str(mod["modId"]).strip() display = mod.get("displayName") display = str(display).strip() if display else modid display = display.replace("${file.jarVersion}", "").strip() or modid version = resolve_version(mod.get("version"), manifest) or "" authors = stringify_authors( mod.get("authors", manifest.get("authors")) ) description = mod.get("description") description = str(description).strip() if description else "" logo = extract_logo(zf, manifest, mod, modid) except (zipfile.BadZipFile, OSError) as exc: print(f" skip (unreadable jar: {exc}): {name}", file=sys.stderr) return None return { "id": modid, "name": display, "version": version, "authors": authors, "description": description, "logo": logo, } def main(): required_dir, origin = resolve_required_dir() jars = sorted(glob.glob(os.path.join(required_dir, "*.jar"))) if not jars: sys.exit(f"no jars found in {required_dir} (from {origin})") # Deterministic logo dir: clear stale PNGs, recreate. if os.path.isdir(LOGOS_DIR): for f in glob.glob(os.path.join(LOGOS_DIR, "*.png")): os.remove(f) else: os.makedirs(LOGOS_DIR, exist_ok=True) os.makedirs(os.path.dirname(MODS_JSON), exist_ok=True) mods = [] skipped = 0 by_id = {} for jar in jars: entry = read_jar(jar) if entry is None: skipped += 1 continue # Dedupe by modId (a modset shouldn't have two, but be safe — first wins). if entry["id"] in by_id: print(f" warn (duplicate modId {entry['id']!r}), keeping first", file=sys.stderr) continue by_id[entry["id"]] = entry mods.append(entry) mods.sort(key=lambda m: m["name"].casefold()) with_logos = sum(1 for m in mods if m["logo"]) out = {"schemaVersion": SCHEMA_VERSION, "count": len(mods), "mods": mods} with open(MODS_JSON, "w", encoding="utf-8") as fh: json.dump(out, fh, indent=2, ensure_ascii=False) fh.write("\n") print(f"source: {required_dir} (from {origin})") print(f"jars: {len(jars)} | mods written: {len(mods)} | skipped: {skipped}") print(f"logos extracted: {with_logos}/{len(mods)} -> {LOGOS_DIR}") print(f"wrote {MODS_JSON}") if __name__ == "__main__": main()