#!/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_file.stem
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:
")
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:
")
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:
")
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'
'
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'
'
else:
hero = 'no base render
'
cards.append(
""
)
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
"""
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())