Add WIP crafting table rendering

This commit is contained in:
object-Object 2023-10-01 02:15:01 -04:00
parent aa7d927e28
commit 0f6050fcca
35 changed files with 440 additions and 148 deletions

9
.vscode/launch.json vendored
View file

@ -16,9 +16,14 @@
"name": "Python: Generate Docs", "name": "Python: Generate Docs",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"module": "hexdoc.scripts.hexdoc", "module": "hexdoc.cli.main",
"args": [ "args": [
"doc/properties.toml", "-o", "out", "--lang", "en_us", "render",
"doc/properties.toml",
"_site/src/docs",
"--lang",
"en_us",
"--allow-missing",
], ],
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false "justMyCode": false

View file

@ -1,5 +1,6 @@
{ {
"watch": ["doc/src/hexdoc/_templates"], "watch": ["doc/src/hexdoc/_templates", "doc/properties.toml"],
"ext": "jinja,html,css,js", "ignore": ["**/generated/**"],
"ext": "jinja,html,css,js,toml,json,json5",
"exec": "hexdoc serve doc/properties.toml --src _site/src/docs --dst _site/dst/docs --lang en_us --allow-missing --release" "exec": "hexdoc serve doc/properties.toml --src _site/src/docs --dst _site/dst/docs --lang en_us --allow-missing --release"
} }

View file

@ -26,6 +26,35 @@ regex = "{^_pattern_regex}"
path = "{^_fabric.package}/FabricHexInitializer.kt" path = "{^_fabric.package}/FabricHexInitializer.kt"
regex = "{^_pattern_regex}" regex = "{^_pattern_regex}"
[minecraft_assets]
# https://github.com/PrismarineJS/minecraft-assets/tree/83e2169afbbce40990d69fc53e5962e4a793d467/data/1.19.1
ref = "83e2169afbbce40990d69fc53e5962e4a793d467"
version = "1.19.1"
[textures]
missing = [
"farmersdelight:skillet",
"hexcasting:*colorizer*",
"hexcasting:amethyst_sconce",
"hexcasting:edified_button",
"hexcasting:edified_pressure_plate",
"hexcasting:edified_slab",
"hexcasting:edified_stairs",
]
[textures.override]
"hexcasting:akashic_connector" = "hexcasting:akashic_ligature"
"hexcasting:directrix/empty" = "hexcasting:circle/directrix/empty/front_dim"
"hexcasting:focus" = "hexcasting:focus_empty"
"hexcasting:impetus/empty" = "hexcasting:circle/impetus/empty/front_dim"
"hexcasting:quenched_allay_shard" = "hexcasting:quenched_shard_0"
"hexcasting:scroll_large" = "hexcasting:scroll_pristine_large"
"hexcasting:scroll_medium" = "hexcasting:scroll_pristine_medium"
"hexcasting:scroll_small" = "hexcasting:scroll_pristine_small"
"hexcasting:scroll" = "hexcasting:scroll_pristine_large"
"hexcasting:slate_block" = "hexcasting:slate"
"hexcasting:spellbook" = "hexcasting:spellbook_empty"
"hexcasting:staff/quenched" = "hexcasting:staff/quenched_0"
[template] [template]
static_dir = "static" static_dir = "static"

View file

@ -38,23 +38,16 @@
not_supported: "Your browser does not support visualizing patterns. Pattern code: {}", not_supported: "Your browser does not support visualizing patterns. Pattern code: {}",
}, },
recipe: {
show: "Click to show recipe: {}",
hide: "Click to hide recipe: {}",
},
pages: { pages: {
patchouli: {
crafting: {
description: "Depicted in the book: The crafting recipe for the",
separator: " and ",
},
},
hexcasting: { hexcasting: {
crafting_multi: {
description: "Depicted in the book: Several crafting recipes, for the",
separator: ", ",
},
brainsweep: { brainsweep: {
description: "Depicted in the book: A mind-flaying recipe producing the", description: "Depicted in the book: A mind-flaying recipe producing the",
separator: "", // this value intentionally left blank separator: "",
}, },
}, },
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

View file

@ -1,8 +1,8 @@
{% import "common/macros.html.jinja" as macros %} {% import "macros/formatting.html.jinja" as formatting %}
<section id="{{ category.id.path }}"> <section id="{{ category.id.path }}">
{% call macros.maybe_spoilered(category) %} {% call formatting.maybe_spoilered(category) %}
{{- macros.section_header(category, "h2", "category-title") }} {{- formatting.section_header(category, "h2", "category-title") }}
{{ category.description|hexdoc_block }} {{ category.description|hexdoc_block }}
{% endcall %} {% endcall %}

View file

@ -1,4 +1,4 @@
{% import "common/macros.html.jinja" as macros -%} {% import "macros/formatting.html.jinja" as formatting -%}
{# put the link target outside of toc-container so jump to top still works in sidebar mode #} {# put the link target outside of toc-container so jump to top still works in sidebar mode #}
<div id="table-of-contents"></div> <div id="table-of-contents"></div>
@ -9,18 +9,18 @@
class="permalink toggle-link small" class="permalink toggle-link small"
data-target="toc-category" data-target="toc-category"
title="{{ _('hexdoc.toc.toggle_all') }}" title="{{ _('hexdoc.toc.toggle_all') }}"
><i class="bi bi-list-nested"></i></a>{{ macros.permalink("table-of-contents", "toc-permalink") }}</span> ><i class="bi bi-list-nested"></i></a>{{ formatting.permalink("table-of-contents", "toc-permalink") }}</span>
</h2> </h2>
{% for category in book.categories.values() if category.entries.values() %} {% for category in book.categories.values() if category.entries.values() %}
<details class="toc-category"> <details class="toc-category">
{# category #} {# category #}
<summary>{{ macros.maybe_spoilered_link(category) }}</summary> <summary>{{ formatting.maybe_spoilered_link(category) }}</summary>
{# list of entries in the category #} {# list of entries in the category #}
<ul> <ul>
{% for entry in category.entries.values() %} {% for entry in category.entries.values() %}
<li>{{ macros.maybe_spoilered_link(entry) }}</li> <li>{{ formatting.maybe_spoilered_link(entry) }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</details> </details>

View file

@ -1,8 +1,8 @@
{% import "common/macros.html.jinja" as macros -%} {% import "macros/formatting.html.jinja" as formatting -%}
<div id="{{ entry.id.path }}"> <div id="{{ entry.id.path }}">
{% call macros.maybe_spoilered(entry) %} {% call formatting.maybe_spoilered(entry) %}
{{- macros.section_header(entry, "h3", "entry-title") }} {{- formatting.section_header(entry, "h3", "entry-title") }}
{% for page in entry.pages +%} {% for page in entry.pages +%}
{% include page.template %} {% include page.template %}

View file

@ -38,15 +38,3 @@
{{ caller() }} {{ caller() }}
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
{# shows the names of all the recipe results in a list of recipes #}
{% macro recipe_block(recipes, result_attribute, description, separator) -%}
<blockquote class="crafting-info">
{{ description }} {{
recipes
|map(attribute="result." ~ result_attribute)
|map("hexdoc_wrap", "code")
|join(separator)
}}.
</blockquote>
{%- endmacro %}

View file

@ -0,0 +1,64 @@
{# show the names of all the recipe results in a list of recipes #}
{% macro generic(recipes, result_attribute, description, separator) -%}
<blockquote class="crafting-info">
{{ description }} {{
recipes
|map(attribute="result."~result_attribute)
|map("hexdoc_wrap", "code")
|join(separator)
}}.
</blockquote>
{%- endmacro %}
{# display a single item, with a badge if the count is greater than 1 #}
{% macro render_item(item, count=1, always_show_count=false) -%}
<div data-toggle="tooltip" title="{{ item.name }}">
<img loading="lazy" alt="Image of {{ item.name }}" src="{{ item.texture }}">
{% if always_show_count or count > 1 %}
<div class="badge">{{ count }}</div>
{% endif %}
</div>
{%- endmacro %}
{% macro render_ingredient(ingredient) -%}
{% if ingredient is sequence %}
{# TODO: properly handle multi-option ingredients #}
{{ render_ingredient(ingredient[0]) }}
{% elif ingredient is none %}
{# air #}
<div></div>
{% elif ingredient.type|string == "hexcasting:mod_conditional" %}
{{ render_ingredient(ingredient.default) }}
{% else %}
{{ render_item(ingredient.item) }}
{% endif %}
{%- endmacro %}
{# render a crafting table for each recipe #}
{% macro crafting_table(recipes) -%}
{% for recipe in recipes %}
{% if loop.index0 > 0 %}<br />{% endif %}
<details class="details-collapsible">
<summary class="collapse-details">
<span class="collapse-recipe-show">{{ _('hexdoc.recipe.show').format(recipe.result.item.name) }}</span>
<span class="collapse-recipe-hide">{{ _('hexdoc.recipe.hide').format(recipe.result.item.name) }}</span>
</summary>
<div class="crafting-table">
<img
alt="Crafting table"
src="{{ 'hexcasting:textures/gui/hexdoc/crafting_table.png'|hexdoc_texture }}"
>
<div class="crafting-table-grid">
{% for item in recipe.ingredients %}
{{ render_ingredient(item) }}
{% endfor %}
</div>
<div class="crafting-table-result">
{{ render_item(recipe.result.item, recipe.result.count) }}
</div>
</div>
</details>
{% endfor %}
{%- endmacro %}

View file

@ -2,7 +2,7 @@ summary {
display: list-item; display: list-item;
} }
details.spell-collapsible { .details-collapsible {
display: inline-block; display: inline-block;
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 4px; border-radius: 4px;
@ -10,31 +10,89 @@ details.spell-collapsible {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
summary.collapse-spell { .collapse-details {
font-weight: bold; font-weight: bold;
margin: -0.5em -0.5em 0; margin: -0.5em -0.5em 0;
padding: 0.5em; padding: 0.5em;
} }
details.spell-collapsible[open] { .details-collapsible[open] {
padding: 0.5em; padding: 0.5em;
} }
details[open] summary.collapse-spell { .details-collapsible[open] .collapse-details {
border-bottom: 1px solid #aaa; border-bottom: 1px solid #aaa;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
details .collapse-spell::before { .details-collapsible .collapse-details.collapse-spell::before {
content: "{{ _('hexdoc.pattern.show') }}"; content: "{{ _('hexdoc.pattern.show') }}";
} }
details[open] .collapse-spell::before { .details-collapsible[open] .collapse-details.collapse-spell::before {
content: "{{ _('hexdoc.pattern.hide') }}"; content: "{{ _('hexdoc.pattern.hide') }}";
} }
blockquote.crafting-info { /* if the collapsible is closed, hide the "hide recipe" message */
font-size: inherit; .details-collapsible:not([open]) > .collapse-details > .collapse-recipe-hide {
display: none;
visibility: hidden;
}
/* if the collapsible is open, hide the "show recipe" message */
.details-collapsible[open] > .collapse-details > .collapse-recipe-show {
display: none;
visibility: hidden;
}
/* we do a bit of crafting (mostly stolen from https://computercraft.info/wiki) */
.crafting-table {
position: relative;
width: 256px;
height: 132px;
background-color: #c6c6c6;
border-radius: 16px;
}
.crafting-table > img { /* background */
image-rendering: pixelated;
width: 100%;
height: 100%;
}
.crafting-table-grid {
position: absolute;
top: 14px;
left: 14px;
display: grid;
grid: repeat(3, 32px) / repeat(3, 32px);
gap: 4px;
}
.crafting-table-result {
position: absolute;
top: 50px;
left: 202px;
}
.crafting-table-result .badge {
position: absolute;
background-color: #292929;
bottom: -4px;
right: -4px;
}
:is(.crafting-table-grid, .crafting-table-result) :is(img, span) {
image-rendering: pixelated;
width: 32px;
height: 32px;
}
.tooltip {
white-space: nowrap;
} }
a.permalink { a.permalink {

View file

@ -297,8 +297,7 @@ function hookSpoiler(elem) {
} }
// these are filled by Jinja // these are filled by Jinja
const ROOT_URL = "{{ props.url }}"; const BOOK_URL = "{{ props.url }}";
const PAGE_URL = "{{ page_url }}";
const VERSION = "{{ version }}"; const VERSION = "{{ version }}";
const LANG = "{{ lang }}"; const LANG = "{{ lang }}";
@ -325,7 +324,7 @@ function versionDropdownItem(sitemap, version) {
path = defaultPath; path = defaultPath;
} }
return dropdownItem(version, ROOT_URL + path); return dropdownItem(version, BOOK_URL + path);
} }
function versionDropdownItems(sitemap, versions) { function versionDropdownItems(sitemap, versions) {
@ -390,7 +389,7 @@ function addDropdowns(sitemap) {
const langs = Object.keys(langPaths).sort(); const langs = Object.keys(langPaths).sort();
document.getElementById("lang-dropdown").append( document.getElementById("lang-dropdown").append(
...langs.map((lang) => dropdownItem(lang, ROOT_URL + langPaths[lang])), ...langs.map((lang) => dropdownItem(lang, BOOK_URL + langPaths[lang])),
); );
// return sitemap for chaining, i guess // return sitemap for chaining, i guess
@ -399,12 +398,12 @@ function addDropdowns(sitemap) {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// fetch the sitemap from the root and use it to generate the navbar // fetch the sitemap from the root and use it to generate the navbar
fetch(`${ROOT_URL}/meta/sitemap.json`) fetch(`${BOOK_URL}/meta/sitemap.json`)
.then(r => r.json()) .then(r => r.json())
.then(addDropdowns) .then(addDropdowns)
.catch(e => console.error(e)) .catch(e => console.error(e))
document.querySelectorAll("details.spell-collapsible").forEach(hookLoad); document.querySelectorAll(".details-collapsible").forEach(hookLoad);
document.querySelectorAll("a.toggle-link").forEach(hookToggle); document.querySelectorAll("a.toggle-link").forEach(hookToggle);
document.querySelectorAll(".spoilered").forEach(hookSpoiler); document.querySelectorAll(".spoilered").forEach(hookSpoiler);
@ -417,3 +416,7 @@ document.addEventListener("DOMContentLoaded", () => {
} }
requestAnimationFrame((t) => tick(t, t)); requestAnimationFrame((t) => tick(t, t));
}); });
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})

View file

@ -1,8 +1,8 @@
{% extends "pages/patchouli/page.html.jinja" %} {% extends "pages/patchouli/page.html.jinja" %}
{% include "common/macros.html.jinja" with context %} {% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block body %} {% block body %}
{{ macros.recipe_block( {{ recipe_macros.generic(
[page.recipe], [page.recipe],
"name", "name",
_("hexdoc.pages.hexcasting.brainsweep.description"), _("hexdoc.pages.hexcasting.brainsweep.description"),

View file

@ -1,12 +1,7 @@
{% extends "pages/patchouli/text.html.jinja" %} {% extends "pages/patchouli/text.html.jinja" %}
{% include "common/macros.html.jinja" with context %} {% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block inner_body %} {% block inner_body %}
{{ macros.recipe_block( {{ recipe_macros.crafting_table(page.recipes) }}
page.recipes,
"item",
_("hexdoc.pages.hexcasting.crafting_multi.description"),
_("hexdoc.pages.hexcasting.crafting_multi.separator"),
) }}
{{ super() }} {{ super() }}
{% endblock inner_body %} {% endblock inner_body %}

View file

@ -1,8 +1,8 @@
{% extends "pages/patchouli/text.html.jinja" %} {% extends "pages/patchouli/text.html.jinja" %}
{% block inner_body %} {% block inner_body %}
<details class="spell-collapsible"> <details class="details-collapsible">
<summary class="collapse-spell"></summary> <summary class="collapse-details collapse-spell"></summary>
{% for pattern in page.patterns %} {% for pattern in page.patterns %}
<canvas <canvas
class="spell-viz" class="spell-viz"

View file

@ -1,12 +1,7 @@
{% extends "pages/patchouli/text.html.jinja" %} {% extends "pages/patchouli/text.html.jinja" %}
{% include "common/macros.html.jinja" %} {% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block inner_body %} {% block inner_body %}
{{ macros.recipe_block( {{ recipe_macros.crafting_table(page.recipes) }}
page.recipes,
"item",
_("hexdoc.pages.patchouli.crafting.description"),
_("hexdoc.pages.patchouli.crafting.separator"),
) }}
{{ super() }} {{ super() }}
{% endblock inner_body %} {% endblock inner_body %}

View file

@ -3,7 +3,7 @@
{% block inner_body %} {% block inner_body %}
<p class="img-wrapper"> <p class="img-wrapper">
{% for image in page.images %} {% for image in page.images %}
<img src="{{ image|hexdoc_texture_url }}"></img> <img src="{{ image|hexdoc_texture }}"></img>
{% endfor %} {% endfor %}
</p> </p>
{{ super() }} {{ super() }}

View file

@ -1,5 +1,5 @@
{% extends "pages/patchouli/page.html.jinja" %} {% extends "pages/patchouli/page.html.jinja" %}
{% import "common/macros.html.jinja" as macros %} {% import "macros/formatting.html.jinja" as macros %}
{% block body %} {% block body %}
{% if page.title is not none %} {% if page.title is not none %}

View file

@ -26,7 +26,7 @@ RequiredPathOption = Annotated[Path, typer.Option()]
UpdateLatestOption = Annotated[bool, typer.Option(envvar="UPDATE_LATEST")] UpdateLatestOption = Annotated[bool, typer.Option(envvar="UPDATE_LATEST")]
ReleaseOption = Annotated[bool, typer.Option(envvar="RELEASE")] ReleaseOption = Annotated[bool, typer.Option(envvar="RELEASE")]
app = typer.Typer() app = typer.Typer(pretty_exceptions_enable=False)
@app.command() @app.command()
@ -158,11 +158,10 @@ def serve(
): ):
book_path = dst.resolve().relative_to(Path.cwd()) book_path = dst.resolve().relative_to(Path.cwd())
base_url = f"http://localhost:{port}" book_url = f"/{book_path.as_posix()}"
book_url = f"{base_url}/{book_path.as_posix()}"
os.environ |= { os.environ |= {
"DEBUG_GITHUBUSERCONTENT": base_url, "DEBUG_GITHUBUSERCONTENT": "",
"GITHUB_PAGES_URL": book_url, "GITHUB_PAGES_URL": book_url,
} }
@ -186,7 +185,9 @@ def serve(
release=release, release=release,
) )
print(f"Serving web book at {book_url} (press ctrl+c to exit)\n") print(
f"Serving web book at http://localhost:{port}{book_url} (press ctrl+c to exit)\n"
)
with HTTPServer(("", port), SimpleHTTPRequestHandler) as httpd: with HTTPServer(("", port), SimpleHTTPRequestHandler) as httpd:
httpd.serve_forever() httpd.serve_forever()

View file

@ -21,7 +21,7 @@ from hexdoc.utils.jinja_extensions import (
IncludeRawExtension, IncludeRawExtension,
hexdoc_block, hexdoc_block,
hexdoc_localize, hexdoc_localize,
hexdoc_texture_url, hexdoc_texture,
hexdoc_wrap, hexdoc_wrap,
) )
from hexdoc.utils.path import write_to_path from hexdoc.utils.path import write_to_path
@ -50,7 +50,7 @@ def create_jinja_env(props: Properties):
"hexdoc_block": hexdoc_block, "hexdoc_block": hexdoc_block,
"hexdoc_wrap": hexdoc_wrap, "hexdoc_wrap": hexdoc_wrap,
"hexdoc_localize": hexdoc_localize, "hexdoc_localize": hexdoc_localize,
"hexdoc_texture_url": hexdoc_texture_url, "hexdoc_texture": hexdoc_texture,
} }
return env return env

View file

@ -4,8 +4,10 @@ __all__ = [
"LocalizedStr", "LocalizedStr",
"Recipe", "Recipe",
"Tag", "Tag",
"RenderedItemStack",
] ]
from .assets import RenderedItemStack
from .i18n import I18n, LocalizedItem, LocalizedStr from .i18n import I18n, LocalizedItem, LocalizedStr
from .recipe import Recipe from .recipe import Recipe
from .tags import Tag from .tags import Tag

View file

@ -0,0 +1,82 @@
import logging
from typing import Self
import requests
from pydantic import Field, ValidationInfo, model_validator
from pydantic.dataclasses import dataclass
from hexdoc.minecraft.i18n import I18nContext, LocalizedStr
from hexdoc.utils import ItemStack, ResourceLocation
from hexdoc.utils.deserialize import cast_or_raise
from hexdoc.utils.model import DEFAULT_CONFIG
from hexdoc.utils.resource_loader import resolve_texture_from_metadata
# 16x16 hashtag icon for tags
TAG_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAANUlEQVQ4y2NgGJRAXV39v7q6+n9cfGTARKllFBvAiOxMUjTevHmTkSouGPhAHA0DWnmBrgAANLIZgSXEQxIAAAAASUVORK5CYII="
MISSING_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAJElEQVQoz2NkwAF+MPzAKs7EQCIY1UAMYMQV3hwMHKOhRD8NAPogBA/DVsDEAAAAAElFTkSuQmCC"
class MinecraftAssetsContext(I18nContext):
minecraft_textures: dict[ResourceLocation, str | None] = Field(default_factory=dict)
@model_validator(mode="after")
def _fetch_minecraft_textures(self) -> Self:
asset_props = self.props.minecraft_assets
url = (
"https://raw.githubusercontent.com/PrismarineJS/minecraft-assets"
f"/{asset_props.ref}/data/{asset_props.version}/texture_content.json"
)
logging.getLogger(__name__).info(f"Fetch textures from {url}")
resp = requests.get(url)
resp.raise_for_status()
textures_list: list[dict[{"name": str, "texture": str | None}]] = resp.json()
for item in textures_list:
id = ResourceLocation("minecraft", item["name"])
# items are first in the list (i think), so prioritize them
if id not in self.minecraft_textures:
self.minecraft_textures[id] = item["texture"]
return self
@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False, kw_only=True)
class RenderedItemStack(ItemStack):
name: LocalizedStr | None = None
texture: str | None = None
@model_validator(mode="after")
def _add_name_and_texture(self, info: ValidationInfo):
"""Loads the recipe from json if the actual value is a resource location str."""
if not info.context:
return self
context = cast_or_raise(info.context, MinecraftAssetsContext)
object.__setattr__(self, "name", context.i18n.localize_item(self))
object.__setattr__(self, "texture", self._get_texture(context))
return self
def _get_texture(self, context: MinecraftAssetsContext):
if self.namespace == "minecraft":
return context.minecraft_textures[self.id]
else:
# check if there's an override to apply
id = context.props.textures.override.get(self.id, self.id)
try:
return resolve_texture_from_metadata(
context.loader.mod_metadata,
id.with_path(f"textures/item/{id.path}.png"),
id.with_path(f"textures/block/{id.path}.png"),
)
except KeyError as e:
for missing_id in context.props.textures.missing:
if id.match(missing_id):
logging.getLogger(__name__).warn(f"No texture for {id}")
return MISSING_TEXTURE
raise KeyError(f"No texture for {id}") from e

View file

@ -20,7 +20,7 @@ from hexdoc.utils.resource_loader import LoaderContext
@total_ordering @total_ordering
class LocalizedStr(HexdocModel): class LocalizedStr(HexdocModel, frozen=True):
"""Represents a string which has been localized.""" """Represents a string which has been localized."""
key: str key: str
@ -83,7 +83,7 @@ class LocalizedStr(HexdocModel):
return self.value < other return self.value < other
class LocalizedItem(LocalizedStr): class LocalizedItem(LocalizedStr, frozen=True):
@classmethod @classmethod
def _localize(cls, i18n: I18n, key: str) -> Self: def _localize(cls, i18n: I18n, key: str) -> Self:
return i18n.localize_item(key) return i18n.localize_item(key)
@ -253,6 +253,10 @@ class I18n(HexdocModel):
def localize_key(self, key: str) -> LocalizedStr: def localize_key(self, key: str) -> LocalizedStr:
return self.localize(f"key.{key}") return self.localize(f"key.{key}")
def localize_tag(self, tag: ResourceLocation):
localized = self.localize(f"tag.{tag.namespace}.{tag.path}")
return LocalizedStr(key=localized.key, value=f"Tag: {localized.value}")
class I18nContext(LoaderContext): class I18nContext(LoaderContext):
i18n: I18n i18n: I18n

View file

@ -10,16 +10,12 @@ __all__ = [
"ItemResult", "ItemResult",
] ]
from .abstract_recipes import Recipe from .abstract_recipes import CraftingRecipe, Recipe
from .ingredients import ( from .ingredients import (
ItemIngredient, ItemIngredient,
ItemIngredientOrList, ItemIngredientOrList,
ItemResult,
MinecraftItemIdIngredient, MinecraftItemIdIngredient,
MinecraftItemTagIngredient, MinecraftItemTagIngredient,
) )
from .recipes import ( from .recipes import CraftingShapedRecipe, CraftingShapelessRecipe
CraftingRecipe,
CraftingShapedRecipe,
CraftingShapelessRecipe,
ItemResult,
)

View file

@ -1,12 +1,15 @@
import logging import logging
from typing import Any from typing import Any, Self
from pydantic import ValidationInfo, model_validator from pydantic import ValidationInfo, model_validator
from pydantic.functional_validators import ModelWrapValidatorHandler
from hexdoc.utils import ResourceLocation, TypeTaggedUnion from hexdoc.utils import ResourceLocation, TypeTaggedUnion
from hexdoc.utils.deserialize import cast_or_raise from hexdoc.utils.deserialize import cast_or_raise
from hexdoc.utils.resource_loader import LoaderContext from hexdoc.utils.resource_loader import LoaderContext
from .ingredients import ItemResult
class Recipe(TypeTaggedUnion, type=None): class Recipe(TypeTaggedUnion, type=None):
id: ResourceLocation id: ResourceLocation
@ -14,23 +17,34 @@ class Recipe(TypeTaggedUnion, type=None):
group: str | None = None group: str | None = None
category: str | None = None category: str | None = None
@model_validator(mode="before") # use wrap validator so we load the file *before* resolving the tagged union
def _pre_root(cls, values: Any, info: ValidationInfo): @model_validator(mode="wrap")
@classmethod
def _pre_root(
cls,
value: Any,
handler: ModelWrapValidatorHandler[Self],
info: ValidationInfo,
) -> Self:
"""Loads the recipe from json if the actual value is a resource location str.""" """Loads the recipe from json if the actual value is a resource location str."""
if not info.context: if not info.context:
return values return handler(value)
context = cast_or_raise(info.context, LoaderContext) context = cast_or_raise(info.context, LoaderContext)
# if necessary, convert the id to a ResourceLocation # if necessary, convert the id to a ResourceLocation
match values: match value:
case str(): case str():
id = ResourceLocation.from_str(values) id = ResourceLocation.from_str(value)
case ResourceLocation(): case ResourceLocation():
id = values id = value
case _: case _:
return values return handler(value)
# load the recipe # load the recipe
_, data = context.loader.load_resource("data", "recipes", id) _, data = context.loader.load_resource("data", "recipes", id)
logging.getLogger(__name__).debug(f"Load {cls} from {id}") logging.getLogger(__name__).debug(f"Load {cls} from {id}")
return data | {"id": id} return handler(data | {"id": id})
class CraftingRecipe(Recipe, type=None):
result: ItemResult

View file

@ -1,4 +1,10 @@
from hexdoc.utils import NoValue, ResourceLocation, TypeTaggedUnion from pydantic import ValidationInfo, model_validator
from hexdoc.minecraft import RenderedItemStack
from hexdoc.minecraft.assets import TAG_TEXTURE
from hexdoc.minecraft.i18n import I18nContext
from hexdoc.utils import HexdocModel, NoValue, ResourceLocation, TypeTaggedUnion
from hexdoc.utils.deserialize import cast_or_raise
class ItemIngredient(TypeTaggedUnion, type=None): class ItemIngredient(TypeTaggedUnion, type=None):
@ -9,8 +15,29 @@ ItemIngredientOrList = ItemIngredient | list[ItemIngredient]
class MinecraftItemIdIngredient(ItemIngredient, type=NoValue): class MinecraftItemIdIngredient(ItemIngredient, type=NoValue):
item: ResourceLocation item: RenderedItemStack
class MinecraftItemTagIngredient(ItemIngredient, type=NoValue): class MinecraftItemTagIngredient(ItemIngredient, type=NoValue):
tag: ResourceLocation tag: ResourceLocation
item: RenderedItemStack | None = None
@model_validator(mode="after")
def _add_item(self, info: ValidationInfo):
if not info.context:
return self
context = cast_or_raise(info.context, I18nContext)
self.item = RenderedItemStack(
namespace=self.tag.namespace,
path=self.tag.path,
name=context.i18n.localize_tag(self.tag),
texture=TAG_TEXTURE,
)
return self
class ItemResult(HexdocModel):
item: RenderedItemStack
count: int = 1

View file

@ -1,24 +1,34 @@
from typing import Iterator
from pydantic import field_validator from pydantic import field_validator
from hexdoc.utils import HexdocModel
from hexdoc.utils.compat import HexVersion from hexdoc.utils.compat import HexVersion
from ..i18n import LocalizedItem from .abstract_recipes import CraftingRecipe
from .abstract_recipes import Recipe
from .ingredients import ItemIngredientOrList from .ingredients import ItemIngredientOrList
class ItemResult(HexdocModel): class CraftingShapelessRecipe(CraftingRecipe, type="minecraft:crafting_shapeless"):
item: LocalizedItem ingredients: list[ItemIngredientOrList]
count: int | None = None
class CraftingShapedRecipe(Recipe, type="minecraft:crafting_shaped"): class CraftingShapedRecipe(CraftingRecipe, type="minecraft:crafting_shaped"):
key: dict[str, ItemIngredientOrList] key: dict[str, ItemIngredientOrList]
pattern: list[str] pattern: list[str]
result: ItemResult
show_notification: bool | None = None show_notification: bool | None = None
@property
def ingredients(self) -> Iterator[ItemIngredientOrList | None]:
for row in self.pattern:
if len(row) > 3:
raise ValueError(f"Expected len(row) <= 3, got {len(row)}: `{row}`")
for item_key in row.ljust(3):
match item_key:
case " ":
yield None
case _:
yield self.key[item_key]
@field_validator("show_notification") @field_validator("show_notification")
@classmethod @classmethod
def _check_show_notification(cls, value: bool | None): def _check_show_notification(cls, value: bool | None):
@ -28,11 +38,3 @@ class CraftingShapedRecipe(Recipe, type="minecraft:crafting_shaped"):
case HexVersion.v0_10_x | HexVersion.v0_9_x: case HexVersion.v0_10_x | HexVersion.v0_9_x:
HexVersion.check(value is None, "show_notification") HexVersion.check(value is None, "show_notification")
return value return value
class CraftingShapelessRecipe(Recipe, type="minecraft:crafting_shapeless"):
ingredients: list[ItemIngredientOrList]
result: ItemResult
CraftingRecipe = CraftingShapedRecipe | CraftingShapelessRecipe

View file

@ -3,13 +3,14 @@ from typing import Self
from pydantic import Field, model_validator from pydantic import Field, model_validator
from hexdoc.minecraft import Tag from hexdoc.minecraft import Tag
from hexdoc.minecraft.assets import MinecraftAssetsContext
from hexdoc.plugin.manager import PluginManagerContext from hexdoc.plugin.manager import PluginManagerContext
from hexdoc.utils import ResourceLocation from hexdoc.utils import ResourceLocation
from .text.formatting import FormattingContext from .text.formatting import FormattingContext
class BookContext(FormattingContext, PluginManagerContext): class BookContext(FormattingContext, PluginManagerContext, MinecraftAssetsContext):
spoilered_advancements: set[ResourceLocation] = Field(default_factory=set) spoilered_advancements: set[ResourceLocation] = Field(default_factory=set)
@model_validator(mode="after") @model_validator(mode="after")

View file

@ -5,7 +5,7 @@ _T = TypeVar("_T")
_P = ParamSpec("_P") _P = ParamSpec("_P")
def must_yield(f: Callable[_P, Iterator[_T]]) -> Callable[_P, Iterator[_T]]: def must_yield_something(f: Callable[_P, Iterator[_T]]) -> Callable[_P, Iterator[_T]]:
"""Raises StopIteration if the wrapped iterator doesn't yield anything.""" """Raises StopIteration if the wrapped iterator doesn't yield anything."""
@functools.wraps(f) @functools.wraps(f)

View file

@ -5,6 +5,7 @@ from jinja2.ext import Extension
from jinja2.parser import Parser from jinja2.parser import Parser
from jinja2.runtime import Context from jinja2.runtime import Context
from markupsafe import Markup from markupsafe import Markup
from pydantic import ConfigDict, validate_call
from hexdoc.minecraft import I18n, LocalizedStr from hexdoc.minecraft import I18n, LocalizedStr
from hexdoc.patchouli import Book, FormatTree from hexdoc.patchouli import Book, FormatTree
@ -12,7 +13,7 @@ from hexdoc.patchouli.book import Book
from hexdoc.patchouli.text import HTMLStream from hexdoc.patchouli.text import HTMLStream
from hexdoc.patchouli.text.formatting import FormatTree from hexdoc.patchouli.text.formatting import FormatTree
from hexdoc.utils.resource import ResourceLocation from hexdoc.utils.resource import ResourceLocation
from hexdoc.utils.resource_loader import HexdocMetadata from hexdoc.utils.resource_loader import HexdocMetadata, resolve_texture_from_metadata
from . import Properties from . import Properties
from .deserialize import cast_or_raise from .deserialize import cast_or_raise
@ -101,13 +102,11 @@ def hexdoc_localize(
@pass_context @pass_context
def hexdoc_texture_url(context: Context, id: ResourceLocation) -> str: @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def hexdoc_texture(context: Context, id: ResourceLocation) -> str:
try: try:
metadata = cast_or_raise( mod_metadata = cast_or_raise(context["mod_metadata"], dict[str, HexdocMetadata])
context["mod_metadata"], return resolve_texture_from_metadata(mod_metadata, id)
dict[str, HexdocMetadata],
)[id.namespace]
return f"{metadata.asset_url}/{metadata.textures[id].as_posix()}"
except Exception as e: except Exception as e:
e.add_note(f"id:\n {id}") e.add_note(f"id:\n {id}")
raise raise

View file

@ -87,6 +87,16 @@ class TemplateProps(StripHiddenModel):
return values return values
class MinecraftAssetsProps(StripHiddenModel):
ref: str
version: str
class TexturesProps(StripHiddenModel):
missing: list[ResourceLocation]
override: dict[ResourceLocation, ResourceLocation]
class Properties(StripHiddenModel): class Properties(StripHiddenModel):
env: EnvironmentVariableProps env: EnvironmentVariableProps
@ -104,6 +114,11 @@ class Properties(StripHiddenModel):
entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set)
minecraft_assets: MinecraftAssetsProps
# FIXME: remove this and get the data from the actual model files
textures: TexturesProps
template: TemplateProps template: TemplateProps
@classmethod @classmethod

View file

@ -90,6 +90,10 @@ class BaseResourceLocation:
def _ser_model(self) -> str: def _ser_model(self) -> str:
return str(self) return str(self)
@property
def id(self) -> ResourceLocation:
return ResourceLocation(self.namespace, self.path)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.namespace}:{self.path}" return f"{self.namespace}:{self.path}"
@ -175,6 +179,9 @@ class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
count: int | None = None count: int | None = None
nbt: str | None = None nbt: str | None = None
def __init_subclass__(cls, **kwargs: Any):
super().__init_subclass__(regex=cls._from_str_regex, **kwargs)
def i18n_key(self, root: str = "item") -> str: def i18n_key(self, root: str = "item") -> str:
# TODO: is this how i18n works????? (apparently, because it's working) # TODO: is this how i18n works????? (apparently, because it's working)
return f"{root}.{self.namespace}.{self.path.replace('/', '.')}" return f"{root}.{self.namespace}.{self.path.replace('/', '.')}"

View file

@ -15,7 +15,7 @@ from pydantic.dataclasses import dataclass
from hexdoc.plugin.manager import PluginManager from hexdoc.plugin.manager import PluginManager
from hexdoc.utils.deserialize import JSONDict, decode_json_dict from hexdoc.utils.deserialize import JSONDict, decode_json_dict
from hexdoc.utils.iterators import must_yield from hexdoc.utils.iterators import must_yield_something
from hexdoc.utils.model import DEFAULT_CONFIG, HexdocModel, ValidationContext from hexdoc.utils.model import DEFAULT_CONFIG, HexdocModel, ValidationContext
from hexdoc.utils.path import strip_suffixes, write_to_path from hexdoc.utils.path import strip_suffixes, write_to_path
@ -47,12 +47,6 @@ class HexdocMetadata(HexdocModel):
def path(cls, modid: str) -> Path: def path(cls, modid: str) -> Path:
return Path(f"{modid}.hexdoc.json") return Path(f"{modid}.hexdoc.json")
def export(self, loader: ModResourceLoader):
loader.export(
self.path(loader.props.modid),
self.model_dump_json(by_alias=True, warnings=False),
)
@dataclass(config=DEFAULT_CONFIG, kw_only=True) @dataclass(config=DEFAULT_CONFIG, kw_only=True)
class ModResourceLoader: class ModResourceLoader:
@ -114,21 +108,23 @@ class ModResourceLoader:
for path_resource_dir in stack.enter_context(resource_dir.load(pm)) for path_resource_dir in stack.enter_context(resource_dir.load(pm))
], ],
) )
loader.mod_metadata |= loader.load_metadata(model_type=HexdocMetadata)
# export this mod's metadata # export this mod's metadata
loader.mod_metadata[props.modid] = metadata = HexdocMetadata( metadata = HexdocMetadata(
book_url=f"{props.url}/v/{version}", book_url=f"{props.url}/v/{version}",
asset_url=props.env.githubusercontent, asset_url=props.env.githubusercontent,
textures=loader._map_own_assets("textures", root=repo_root), textures=loader._map_own_assets("textures", root=repo_root),
sounds=loader._map_own_assets("sounds", root=repo_root), sounds=loader._map_own_assets("sounds", root=repo_root),
) )
metadata.export(loader) loader.mod_metadata[props.modid] = metadata
loader.export(
metadata.path(loader.props.modid),
metadata.model_dump_json(by_alias=True, warnings=False),
)
yield loader yield loader
def __post_init__(self):
self.mod_metadata |= self.load_metadata(model_type=HexdocMetadata)
def _map_own_assets(self, folder: str, *, root: str | Path): def _map_own_assets(self, folder: str, *, root: str | Path):
return { return {
id: path.resolve().relative_to(root) id: path.resolve().relative_to(root)
@ -170,7 +166,7 @@ class ModResourceLoader:
return metadata return metadata
# TODO: maybe this should take lang as a variable? # TODO: maybe this should take lang as a variable?
@must_yield @must_yield_something
def load_book_assets( def load_book_assets(
self, self,
book_id: ResourceLocation, book_id: ResourceLocation,
@ -494,3 +490,17 @@ class LoaderContext(ValidationContext):
@property @property
def props(self): def props(self):
return self.loader.props return self.loader.props
def resolve_texture_from_metadata(
mod_metadata: dict[str, HexdocMetadata],
id: ResourceLocation,
*fallbacks: ResourceLocation,
) -> str:
try:
metadata = mod_metadata[id.namespace]
return f"{metadata.asset_url}/{metadata.textures[id].as_posix()}"
except KeyError:
if not fallbacks:
raise
return resolve_texture_from_metadata(mod_metadata, fallbacks[0], *fallbacks[1:])

View file

@ -33,15 +33,16 @@ classifiers = [
] ]
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"typing_extensions>=4.6.1,<5", "typing_extensions~=4.6",
"importlib_resources>=6.0.1,<7", "importlib_resources~=6.0",
"more_itertools>=10.1.0,<11", "more_itertools~=10.1",
"pydantic>=2.3.0,<3,!=2.4.0", "pydantic~=2.3,!=2.4.0",
"pydantic_settings>=2.0.3,<3", "pydantic_settings~=2.0",
"Jinja2>=3.1.2,<4", "Jinja2~=3.1",
"pyjson5>=1.6.3,<2", "pyjson5~=1.6",
"pluggy>=1.3.0,<2", "pluggy~=1.3",
"typer[all]>=0.9.0,<1", "typer[all]~=0.9.0",
"requests~=2.31",
] ]
dynamic = ["version"] dynamic = ["version"]