Skip to content

Instantly share code, notes, and snippets.

@ssokolow
Last active July 7, 2026 01:44
Show Gist options
  • Select an option

  • Save ssokolow/0fa340fa0a6f6c53cb2dac346641ad95 to your computer and use it in GitHub Desktop.

Select an option

Save ssokolow/0fa340fa0a6f6c53cb2dac346641ad95 to your computer and use it in GitHub Desktop.
A generator for listing the mods in a zipped up Stardew Valley/SMAPI Mods folder
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Generate a mod listing from the manifest.json files in a zipped up SMAPI
Mods folder.
Requires:
- jinja2 (`python3-jinja2` on Debian-family Linux distros) for the HHTML output
- json5 (`python3-json5` on Debian-family Linux distros) for JSONC parsing
- natsort (`python3-natsort` on Debian-family Linux distros)
"""
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__appname__ = "Generate Modlists"
__version__ = "0.1"
__license__ = "MIT"
import html
import logging
import os
import re
import time
import zipfile
from jinja2 import Environment, DictLoader
import json5 as json # type: ignore
import natsort
from typing import Dict, List, Mapping, Union
log = logging.getLogger(__name__)
re_nexus_updatekey = re.compile(r'Nexus:(\d+)')
BASE_TMPL = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="never">
<meta name="referrer" content="no-referrer">
<title>Mods in {{zipname}}</title>
<style>
body {
font-family: sans-serif;
margin: 1em
}
h1 small { font-size: 60%; }
dl { margin-left: 3ex; }
dt {
font-weight: bold;
margin-bottom: 0.5ex;
}
dd + dt { margin-top: 2ex; }
dd { margin-bottom: 1ex; }
dd.ver:has(+ dd.ver) { margin-bottom: 0.5ex; }
dd.ver {
font-weight: bold;
font-size: 80%;
}
.note {
margin: 1em;
padding: 1em;
border: 1px solid #aaaaff;
background: #eeeeff;
}
</style>
</head>
<body>
<h1> Mods in <a href="{{zipname}}">{{zipname}}</a>
<small>({{zipsize_pretty}}, Updated: {{zipdate}})</small></h1>
<p class='note'><strong>NOTE:</strong> This list is generated from the metadata inside the mods and cannot fully account for cases like <a href="https://www.nexusmods.com/stardewvalley/mods/5787">East Scarp</a> and <a href="https://www.nexusmods.com/stardewvalley/mods/3753">Stardew Valley Expanded</a> where the mod is a single download but multiple mods internally.</p>
{%- macro render_mod(mod) -%}
<dt>{% if mod.url -%}
<a href="{{mod.url}}">{{mod.name}}</a>
{%- else -%}
{{- mod.name -}}
{%- endif %} v{{mod.version}} by {{mod.author}}</dt>
{% if mod.minimum_sdv %}<dd class='ver'>Requires Stardew Valley version
{{ mod.minimum_sdv }} or newer{% endif %}
{% if mod.minimum_smapi %}<dd class='ver'>Requires SMAPI version
{{ mod.minimum_smapi }} or newer{% endif %}
<dd>{{mod.description}}</dd>
{%- endmacro -%}
<dl>
{% for mod in mods %}
{% if mod.submods %}
<dt>{{mod.name}}</dt>
<dd><dl>
{% for submod in mod.submods %}
{{ render_mod(submod) }}
{% endfor %}
</dl></dd>
{% else %}
{{ render_mod(mod) }}
{% endif %}
{% endfor %}
</dl>
</body>
</html>
"""
def sizeof_fmt(num, suffix="B"):
"""Source: https://stackoverflow.com/a/1094933"""
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Yi{suffix}"
def render_html(mods: List[Mapping[str, Union[str, List[Dict[str, str]]]]],
zip_path: str):
"""Render the prepared mod data into an HTML file"""
env = Environment(
loader=DictLoader({'index.html': BASE_TMPL}),
autoescape=True,
trim_blocks=True,
lstrip_blocks=True,
)
template = env.get_template('index.html')
fbase, fext = os.path.splitext(zip_path)
with open(fbase + '.info.html', 'w') as fobj:
zipstat = os.stat(zip_path)
fobj.write(template.render(
zipname=html.escape(os.path.basename(zip_path)),
zipdate=time.strftime('%Y-%m-%d', time.gmtime(zipstat.st_mtime)),
zipsize_pretty=sizeof_fmt(zipstat.st_size),
mods=mods
))
def process_manifest(contents: bytes) -> Dict[str, str]:
"""Parse relevant fields from a SMAPI manifest.json
Does not add the subpath key that ``group_mods`` requires.
"""
data = json.loads(contents)
result = {
'name': data.get('Name', '(untitled)'),
'author': data.get('Author', '(unspecified)'),
'version': data.get('Version', 'v?.?.?'),
'description': data.get('Description', '(no description)'),
'minimum_smapi': data.get('MinimumApiVersion'),
'minimum_sdv': data.get('MinimumGameVersion'),
'url': None,
}
for key in data.get('UpdateKeys', []):
match_obj = re_nexus_updatekey.match(key)
if match_obj:
result['url'] = ('https://www.nexusmods.com/stardewvalley/mods/' +
match_obj.group(1))
return result
def group_mods(mods: List[Dict[str, str]]) -> List[
Mapping[str, Union[str, List[Dict[str, str]]]]]:
"""Group mods into ``submods`` keys based on their paths"""
def _inner(mods: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]:
output: Dict[str, List[Dict[str, str]]] = {}
for mod in mods:
subpath = mod.get('subpath')
if subpath:
if '/' in subpath:
head, tail = subpath.split('/', 1)
else:
head, tail = subpath, None
output.setdefault(head, []).append(mod)
if tail:
mod['subpath'] = tail
else:
del mod['subpath']
while len(output) == 1:
output = _inner(list(output.values())[0])
return output
grouped = _inner(mods)
result: List[Mapping[str, Union[str, List[Dict[str, str]]]]] = []
for key, value in grouped.items():
if len(value) == 1:
result.append(value[0])
else:
result.append({
'name': key,
'submods': value
})
return result
def process_file(path: str) -> None:
"""Process a single zipped mod pack and write a matching .info.html
Return without doing anything if ``path`` is not a zip.
"""
if not zipfile.is_zipfile(path):
log.debug("Skipping file (not a Zip): %s", path)
return
mods = []
with zipfile.ZipFile(path, 'r') as zobj:
for zpath in zobj.namelist():
if os.path.basename(zpath).lower() == 'manifest.json':
try:
mod = process_manifest(zobj.read(zpath))
mod['subpath'] = os.path.dirname(zpath)
mods.append(mod)
except Exception as err:
log.error("Could not parse %r in %r: %s", zpath, path, err)
mods.sort(key=natsort.natsort_keygen(key=lambda o: o.get('name')))
grouped_mods = group_mods(mods)
grouped_mods.sort(key=natsort.natsort_keygen(key=lambda o: o.get('name')))
render_html(grouped_mods, path)
def process_arg(path: str) -> None:
"""Recurse the given paths, dispatching to process_file"""
if os.path.isdir(path):
for parent, dirs, files in os.walk(path):
dirs.sort()
files.sort()
for fname in files:
process_file(os.path.join(parent, fname))
else:
process_file(path)
def main() -> None:
"""The main entry point, compatible with setuptools entry points."""
from argparse import ArgumentParser, RawDescriptionHelpFormatter
parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter,
description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
parser.add_argument('-V', '--version', action='version',
version="%%(prog)s v%s" % __version__)
parser.add_argument('-v', '--verbose', action="count",
default=2, help="Increase the verbosity. Use twice for extra effect.")
parser.add_argument('-q', '--quiet', action="count",
default=0, help="Decrease the verbosity. Use twice for extra effect.")
parser.add_argument('path', action="store", nargs="+",
help="Path to operate on")
# Reminder: %(default)s can be used in help strings.
args = parser.parse_args()
# Set up clean logging to stderr
log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING,
logging.INFO, logging.DEBUG]
args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1)
args.verbose = max(args.verbose, 0)
logging.basicConfig(level=log_levels[args.verbose],
format='%(levelname)s: %(message)s')
for path in args.path:
process_arg(path)
if __name__ == '__main__': # pragma: nocover
main()
# vim: set sw=4 sts=4 expandtab :
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment