Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
9.0 KiB
Python
Executable File
254 lines
9.0 KiB
Python
Executable File
#!/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/<modid>.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 <forgemods-required-dir-or-dist-root>
|
|
# 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/<modid>.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/<id>/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()
|