507 lines
17 KiB
Python
507 lines
17 KiB
Python
#!/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: <img src=\"{record['render_path']}\" alt=\"{record['item']}\" width=\"160\" />")
|
|
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: <img src=\"{case['render_path']}\" alt=\"{case['when']}\" width=\"160\" />")
|
|
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: <img src=\"{case['render_path']}\" alt=\"{case['when']}\" width=\"160\" />")
|
|
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'<img src="{html.escape(render_path)}" alt="{html.escape(case["when"])}" />'
|
|
else:
|
|
preview = '<div class="preview-missing">render pending</div>'
|
|
|
|
preview_items.append(
|
|
"<article class='variant'>"
|
|
f"<div class='variant-preview'>{preview}</div>"
|
|
f"<div class='variant-name'>{html.escape(case['when'])}</div>"
|
|
f"<div class='variant-model'>{html.escape(case['model'])}</div>"
|
|
"</article>"
|
|
)
|
|
|
|
render_path = record["render_path"]
|
|
if render_path:
|
|
hero = f'<img src="{html.escape(render_path)}" alt="{html.escape(record["item"])}" />'
|
|
else:
|
|
hero = '<div class="hero-missing">no base render</div>'
|
|
|
|
cards.append(
|
|
"<section class='card'>"
|
|
f"<div class='hero'>{hero}</div>"
|
|
"<div class='meta'>"
|
|
f"<h2>{html.escape(record['item'])}</h2>"
|
|
f"<p class='source'>{html.escape(record['source_file'])}</p>"
|
|
f"<p class='fallback'>Fallback model: {html.escape(record['fallback_model'])}</p>"
|
|
f"<div class='variants'>{''.join(preview_items)}</div>"
|
|
"</div>"
|
|
"</section>"
|
|
)
|
|
|
|
template = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Minecraft Renameable Items</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: dark;
|
|
--bg: #11150f;
|
|
--bg-soft: #182017;
|
|
--card: rgba(22, 28, 20, 0.88);
|
|
--line: rgba(182, 214, 159, 0.18);
|
|
--text: #edf4e8;
|
|
--muted: #a7b4a1;
|
|
--accent: #9de57b;
|
|
--accent-soft: rgba(157, 229, 123, 0.14);
|
|
--shadow: 0 24px 70px rgba(0, 0, 0, 0.38);
|
|
}}
|
|
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(157, 229, 123, 0.16), transparent 26%),
|
|
radial-gradient(circle at top right, rgba(86, 143, 52, 0.2), transparent 30%),
|
|
linear-gradient(180deg, #0d110c 0%, var(--bg) 32%, #0c100b 100%);
|
|
min-height: 100vh;
|
|
}}
|
|
|
|
.shell {{ max-width: 1280px; margin: 0 auto; padding: 40px 20px 64px; }}
|
|
.hero-panel {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 28px;
|
|
background: linear-gradient(135deg, rgba(28, 39, 24, 0.96), rgba(16, 20, 14, 0.94));
|
|
box-shadow: var(--shadow);
|
|
padding: 28px;
|
|
margin-bottom: 28px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}}
|
|
.hero-panel::after {{
|
|
content: "";
|
|
position: absolute;
|
|
inset: -40% -20% auto auto;
|
|
width: 360px;
|
|
height: 360px;
|
|
background: radial-gradient(circle, rgba(157, 229, 123, 0.2), transparent 68%);
|
|
pointer-events: none;
|
|
}}
|
|
.kicker {{ text-transform: uppercase; letter-spacing: 0.2em; color: var(--accent); font-size: 0.78rem; margin: 0 0 10px; }}
|
|
h1 {{ margin: 0; font-size: clamp(2rem, 3.8vw, 4rem); line-height: 0.96; max-width: 10ch; }}
|
|
.summary {{ color: var(--muted); max-width: 72ch; margin: 16px 0 0; font-size: 1rem; line-height: 1.6; }}
|
|
.stats {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 22px; }}
|
|
.stat {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
padding: 14px 16px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}}
|
|
.stat span {{ display: block; color: var(--muted); font-size: 0.82rem; margin-bottom: 4px; }}
|
|
.stat strong {{ font-size: 1.4rem; }}
|
|
.grid {{ display: grid; gap: 18px; }}
|
|
.card {{
|
|
display: grid;
|
|
grid-template-columns: 210px minmax(0, 1fr);
|
|
gap: 18px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 24px;
|
|
background: var(--card);
|
|
box-shadow: var(--shadow);
|
|
padding: 18px;
|
|
}}
|
|
.hero {{
|
|
min-height: 210px;
|
|
border-radius: 20px;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
background: linear-gradient(160deg, rgba(157, 229, 123, 0.09), rgba(255, 255, 255, 0.02));
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: hidden;
|
|
}}
|
|
.hero img, .variant-preview img {{ width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }}
|
|
.hero-missing, .preview-missing {{
|
|
color: var(--muted);
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
padding: 16px;
|
|
text-align: center;
|
|
}}
|
|
.meta h2 {{ margin: 2px 0 8px; font-size: 1.7rem; }}
|
|
.source, .fallback {{ color: var(--muted); margin: 0 0 8px; }}
|
|
.variants {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 16px; }}
|
|
.variant {{
|
|
border: 1px solid rgba(157, 229, 123, 0.14);
|
|
border-radius: 18px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
padding: 10px;
|
|
}}
|
|
.variant-preview {{
|
|
aspect-ratio: 1 / 1;
|
|
border-radius: 14px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
margin-bottom: 10px;
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
}}
|
|
.variant-name {{ font-weight: 700; margin-bottom: 4px; }}
|
|
.variant-model {{ color: var(--muted); font-size: 0.82rem; word-break: break-word; }}
|
|
.footer {{ color: var(--muted); margin-top: 24px; font-size: 0.9rem; }}
|
|
@media (max-width: 860px) {{
|
|
.stats, .card {{ grid-template-columns: 1fr; }}
|
|
.hero {{ min-height: 160px; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<section class="hero-panel">
|
|
<p class="kicker">Minecraft resource pack browser</p>
|
|
<h1>Renameable item catalog</h1>
|
|
<p class="summary">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.</p>
|
|
<div class="stats">
|
|
<div class="stat"><span>Base items</span><strong>{len(records)}</strong></div>
|
|
<div class="stat"><span>Rename variants</span><strong>{total_cases}</strong></div>
|
|
<div class="stat"><span>Render mode</span><strong>local Python renderer</strong></div>
|
|
</div>
|
|
</section>
|
|
<section class="grid">
|
|
{''.join(cards)}
|
|
</section>
|
|
<p class="footer">Generated from the contents of the resource pack.</p>
|
|
</main>
|
|
</body>
|
|
</html>"""
|
|
|
|
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 <pack-root>/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()) |