#!/usr/bin/env python3 from __future__ import annotations import argparse import html import json import shutil from pathlib import Path from typing import Any def load_json(path: Path) -> Any: with path.open("r", encoding="utf-8") as handle: return json.load(handle) def iter_custom_name_selects(node: Any) -> list[dict[str, Any]]: selects: list[dict[str, Any]] = [] if isinstance(node, dict): if node.get("type") == "select" and node.get("component") == "minecraft:custom_name": selects.append(node) for value in node.values(): selects.extend(iter_custom_name_selects(value)) elif isinstance(node, list): for value in node: selects.extend(iter_custom_name_selects(value)) return selects def resolve_model_ref(model_node: Any) -> str | None: if not isinstance(model_node, dict): return None model_ref = model_node.get("model") if isinstance(model_ref, str): return model_ref return None def namespace_and_path(model_ref: str) -> tuple[str, str] | None: if ":" not in model_ref: return None namespace, path = model_ref.split(":", 1) return namespace, path def item_name_from_fallback(fallback_model: str) -> str | None: prefix = "minecraft:item/" if fallback_model.startswith(prefix): return fallback_model[len(prefix) :] return None def render_path_for_model(model_ref: str) -> Path | None: resolved = namespace_and_path(model_ref) if resolved is None: return None namespace, path = resolved if namespace == "minecraft" and path.startswith("item/"): return None return Path("renders") / namespace / Path(path).with_suffix(".png") def collect_renameable_items(pack_root: Path) -> list[dict[str, Any]]: item_files = sorted(pack_root.glob("assets/*/items/*.json")) records: list[dict[str, Any]] = [] for item_file in item_files: try: data = load_json(item_file) except json.JSONDecodeError: continue selects = iter_custom_name_selects(data) if not selects: continue for select_node in selects: fallback_ref = resolve_model_ref(select_node.get("fallback")) if fallback_ref is None: continue item_name = item_name_from_fallback(fallback_ref) if item_name is None: continue cases: list[dict[str, Any]] = [] for case in select_node.get("cases", []): if not isinstance(case, dict): continue case_model_ref = resolve_model_ref(case.get("model")) if case_model_ref is None: continue case_render_path = render_path_for_model(case_model_ref) cases.append( { "when": str(case.get("when", "")), "model": case_model_ref, "render_path": case_render_path.as_posix() if case_render_path else None, } ) cases.sort(key=lambda case: case["when"]) base_render_path = render_path_for_model(fallback_ref) records.append( { "item": item_name, "fallback_model": fallback_ref, "source_file": item_file.relative_to(pack_root).as_posix(), "render_path": base_render_path.as_posix() if base_render_path else None, "cases": cases, } ) records.sort(key=lambda record: (record["item"], record["source_file"])) return records def copy_existing_renders(pack_root: Path, output_dir: Path) -> None: source_renders = pack_root / "renders" if not source_renders.exists(): return for source_file in source_renders.rglob("*.png"): relative_path = source_file.relative_to(source_renders) destination = output_dir / "renders" / relative_path destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_file, destination) def build_markdown(records: list[dict[str, Any]]) -> str: total_cases = sum(len(record["cases"]) for record in records) lines: list[str] = [] lines.append("# Renameable Item Catalog") lines.append("") lines.append("Generated from the resource pack item model JSON.") lines.append("") lines.append("## Summary") lines.append("") lines.append(f"- Base items: {len(records)}") lines.append(f"- Rename variants: {total_cases}") lines.append("- Render mode: local Python renderer") lines.append("") lines.append("Sections are sorted by the item they are generated from.") for record in records: lines.append(f"## {record['item']}") lines.append("") lines.append(f"- Generated from: `{record['source_file']}`") lines.append(f"- Fallback model: `{record['fallback_model']}`") if record["render_path"]: lines.append(f"- Base render: `{record['render_path']}`") lines.append(f" - Preview: \"{record['item']}\"") else: lines.append("- Base render: not present yet") lines.append("") lines.append("### Rename variants") lines.append("") if not record["cases"]: lines.append("- None found") lines.append("") continue for case in record["cases"]: if case["render_path"]: render_line = f"`{case['render_path']}`" lines.append(f"- `{case['when']}` -> `{case['model']}`") lines.append(f" - Preview: \"{case['when']}\"") continue else: render_line = "not present yet" lines.append(f"- `{case['when']}` -> `{case['model']}` ({render_line})") lines.append("") lines.append("---") lines.append("") lines.append("This file is generated. Add your renderer output under the matching `renders/` paths to populate image previews later.") return "\n".join(lines) def build_readme(records: list[dict[str, Any]]) -> str: namespaces = sorted( { case_namespace(case) for record in records for case in record["cases"] if case_namespace(case) } ) lines: list[str] = [] lines.append("# Renameable Item Catalog") lines.append("") lines.append("Generated from the resource pack item model JSON.") lines.append("") lines.append("## Namespace catalogs") lines.append("") for namespace in namespaces: lines.append(f"- [renameable-items-{namespace}.md](build/renameable-items-{namespace}.md)") lines.append("") lines.append("Each namespace catalog is generated from the matching model namespace in the resource pack.") return "\n".join(lines) def case_namespace(case: dict[str, Any]) -> str | None: resolved = namespace_and_path(case["model"]) if resolved is None: return None return resolved[0] def build_namespace_catalog(records: list[dict[str, Any]], namespace: str) -> str: namespace_records = [ record for record in records if any(case_namespace(case) == namespace for case in record["cases"]) ] total_cases = sum(1 for record in namespace_records for case in record["cases"] if case_namespace(case) == namespace) lines: list[str] = [] lines.append(f"# Renameable Item Catalog - {namespace}") lines.append("") lines.append("Generated from the resource pack item model JSON.") lines.append("") lines.append("## Summary") lines.append("") lines.append(f"- Base items: {len(namespace_records)}") lines.append(f"- Rename variants: {total_cases}") lines.append("- Render mode: local Python renderer") lines.append("") for record in namespace_records: lines.append(f"## {record['item']}") lines.append("") lines.append(f"- Generated from: `{record['source_file']}`") lines.append(f"- Fallback model: `{record['fallback_model']}`") lines.append("") lines.append("### Rename variants") lines.append("") namespace_cases = [case for case in record["cases"] if case_namespace(case) == namespace] if not namespace_cases: lines.append("- None found") lines.append("") continue for case in namespace_cases: if case["render_path"]: lines.append(f"- `{case['when']}` -> `{case['model']}`") lines.append(f" - Preview: \"{case['when']}\"") else: lines.append(f"- `{case['when']}` -> `{case['model']}` (not present yet)") lines.append("") lines.append("---") lines.append("") lines.append("This file is generated. Add your renderer output under the matching `renders/` paths to populate image previews later.") return "\n".join(lines) def build_html(records: list[dict[str, Any]]) -> str: total_cases = sum(len(record["cases"]) for record in records) cards: list[str] = [] for record in records: preview_items: list[str] = [] for case in record["cases"]: render_path = case["render_path"] if render_path: preview = f'{html.escape(case[' else: preview = '
render pending
' preview_items.append( "
" f"
{preview}
" f"
{html.escape(case['when'])}
" f"
{html.escape(case['model'])}
" "
" ) render_path = record["render_path"] if render_path: hero = f'{html.escape(record[' else: hero = '
no base render
' cards.append( "
" f"
{hero}
" "
" f"

{html.escape(record['item'])}

" f"

{html.escape(record['source_file'])}

" f"

Fallback model: {html.escape(record['fallback_model'])}

" f"
{''.join(preview_items)}
" "
" "
" ) template = f""" Minecraft Renameable Items

Minecraft resource pack browser

Renameable item catalog

This readme is generated from the pack model JSON. It lists every item that changes by custom name, and it is ready to consume rendered PNGs once a renderer job writes them into the output tree.

Base items{len(records)}
Rename variants{total_cases}
Render modelocal Python renderer
{''.join(cards)}
""" return template def main() -> int: parser = argparse.ArgumentParser(description="Build the renameable-item readme from a Minecraft resource pack.") parser.add_argument("--pack-root", default="resourcepack", help="Path to the resource pack root") parser.add_argument("--output-dir", default="site/dist", help="Directory for generated site files") parser.add_argument("--readme-path", default="README.md", help="Path to the generated README file") parser.add_argument("--copy-renders", action="store_true", help="Copy pre-rendered PNGs from /renders into the output") args = parser.parse_args() pack_root = Path(args.pack_root).resolve() output_dir = Path(args.output_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) records = collect_renameable_items(pack_root) manifest_path = output_dir / "renameable-items.json" manifest_path.write_text(json.dumps(records, indent=2), encoding="utf-8") readme_path = Path(args.readme_path).resolve() readme_path.write_text(build_readme(records), encoding="utf-8") namespace_names = sorted( { case_namespace(case) for record in records for case in record["cases"] if case_namespace(case) } ) for namespace in namespace_names: namespace_catalog = build_namespace_catalog(records, namespace) (output_dir / f"renameable-items-{namespace}.md").write_text(namespace_catalog, encoding="utf-8") if args.copy_renders: copy_existing_renders(pack_root, output_dir) return 0 if __name__ == "__main__": raise SystemExit(main())