diff --git a/doc/properties.toml b/doc/properties.toml index 95bee90b..aaaf43be 100644 --- a/doc/properties.toml +++ b/doc/properties.toml @@ -37,8 +37,8 @@ show_landing_text = true hexcasting = "https://raw.githubusercontent.com/gamma-delta/HexMod/main/Common/src/main/resources" [i18n] -lang = "en_us" -filename = "{lang}.json" +default_lang = "en_us" +filename = "{default_lang}.json" [i18n.extra] "item.minecraft.amethyst_shard" = "Amethyst Shard" "item.minecraft.budding_amethyst" = "Budding Amethyst" diff --git a/doc/pyproject.toml b/doc/pyproject.toml index 255810ee..349e0b7d 100644 --- a/doc/pyproject.toml +++ b/doc/pyproject.toml @@ -2,12 +2,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.build] -packages = ["src/hexdoc"] - [project] -name = "HexDoc" +name = "hexdoc" version = "0.1.0" authors = [ { name="Alwinfy" }, @@ -26,13 +23,17 @@ dependencies = [ dev = [ "black==23.7.0", "isort==5.12.0", - "pytest==7.3.1", - "syrupy==4.0.2", + "pytest~=7.3.1", + "syrupy~=4.0.2", + "hatchling", ] [project.scripts] hexdoc = "hexdoc.hexdoc:main" +[project.entry-points."hexdoc.book_data"] +"hexcasting:thehexbook" = "hexdoc._book_data" + [project.entry-points."hexdoc.Page"] hexdoc-patchouli = "hexdoc.patchouli.page.pages" hexdoc-hexcasting = "hexdoc.hexcasting.page.hex_pages" @@ -46,6 +47,10 @@ hexdoc-minecraft = "hexdoc.minecraft.recipe.ingredients" hexdoc-hexcasting = "hexdoc.hexcasting.hex_recipes" +[tool.hatch.build] +packages = ["src/hexdoc"] + + [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] markers = [ @@ -103,10 +108,6 @@ reportUntypedBaseClass = "error" reportUntypedClassDecorator = "error" reportUntypedFunctionDecorator = "error" reportUntypedNamedTuple = "error" -reportUnusedClass = "error" -reportUnusedExpression = "error" -reportUnusedFunction = "error" -reportUnusedVariable = "error" reportWildcardImportFromLibrary = "error" reportMissingTypeArgument = "warning" @@ -116,6 +117,10 @@ reportUnknownLambdaType = "warning" reportUnknownMemberType = "warning" reportUnnecessaryComparison = "warning" reportUnnecessaryIsInstance = "warning" +reportUnusedClass = "warning" +reportUnusedExpression = "warning" +reportUnusedFunction = "warning" reportUnusedImport = "warning" +reportUnusedVariable = "warning" reportUnknownVariableType = "none" diff --git a/doc/src/hexdoc/_book_data/__init__.py b/doc/src/hexdoc/_book_data/__init__.py new file mode 100644 index 00000000..351f2846 --- /dev/null +++ b/doc/src/hexdoc/_book_data/__init__.py @@ -0,0 +1 @@ +BOOK_DATA_PATH = "data.json" diff --git a/doc/src/hexdoc/_templates/__init__.py b/doc/src/hexdoc/_templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/doc/src/hexdoc/minecraft/i18n.py b/doc/src/hexdoc/minecraft/i18n.py index e93ba824..56831844 100644 --- a/doc/src/hexdoc/minecraft/i18n.py +++ b/doc/src/hexdoc/minecraft/i18n.py @@ -16,7 +16,7 @@ from hexdoc.utils import ( Properties, ResourceLocation, ) -from hexdoc.utils.deserialize import isinstance_or_raise, load_json +from hexdoc.utils.deserialize import isinstance_or_raise, load_json_dict class I18nContext(TypedDict): @@ -115,7 +115,7 @@ class I18n: # we could also use that to ensure all i18n files have the same set of keys lang_dir = props.resources_dir / "assets" / props.modid / "lang" path = lang_dir / props.i18n.filename - raw_lookup = load_json(path) | (props.i18n.extra or {}) + raw_lookup = load_json_dict(path) | (props.i18n.extra or {}) # validate and insert self.lookup = {} diff --git a/doc/src/hexdoc/minecraft/recipe/abstract_recipes.py b/doc/src/hexdoc/minecraft/recipe/abstract_recipes.py index 42f5eb83..138d22d1 100644 --- a/doc/src/hexdoc/minecraft/recipe/abstract_recipes.py +++ b/doc/src/hexdoc/minecraft/recipe/abstract_recipes.py @@ -3,7 +3,7 @@ from typing import Any, Self, cast from pydantic import ValidationInfo, model_validator from hexdoc.utils import AnyPropsContext, ResourceLocation, TypeTaggedUnion -from hexdoc.utils.deserialize import load_json +from hexdoc.utils.deserialize import load_json_dict class Recipe(TypeTaggedUnion[AnyPropsContext], group="hexdoc.Recipe", type=None): @@ -35,7 +35,7 @@ class Recipe(TypeTaggedUnion[AnyPropsContext], group="hexdoc.Recipe", type=None) path = recipe_dir / f"{id.path}.json" if recipe_dir == context["props"].default_recipe_dir: # only load from one file - values = load_json(path) | {"id": id} + values = load_json_dict(path) | {"id": id} elif not path.exists(): # this is to ensure the recipe at least exists on all platforms # because we've had issues with that before (eg. Hexal's Mote Nexus) diff --git a/doc/src/hexdoc/patchouli/book.py b/doc/src/hexdoc/patchouli/book.py index c075191b..fdceebc1 100644 --- a/doc/src/hexdoc/patchouli/book.py +++ b/doc/src/hexdoc/patchouli/book.py @@ -1,3 +1,5 @@ +from importlib import resources +from importlib.metadata import entry_points from typing import Any, Generic, Literal, Self, cast from pydantic import Field, ValidationInfo, model_validator @@ -12,7 +14,7 @@ from hexdoc.utils import ( ResLoc, ResourceLocation, ) -from hexdoc.utils.deserialize import isinstance_or_raise, load_json +from hexdoc.utils.deserialize import isinstance_or_raise, load_json_dict from .book_models import AnyBookContext, BookContext from .category import Category @@ -33,8 +35,7 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]): """ # not in book.json - context: AnyBookContext = Field(default_factory=dict) - categories: dict[ResourceLocation, Category] = Field(default_factory=dict) + i18n_data: I18n # required name: LocalizedStr @@ -65,51 +66,73 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]): custom_book_item: ItemStack | None = None show_toasts: bool = True use_blocky_font: bool = False - do_i18n: bool = Field(default=False, alias="i18n") + i18n: bool = False macros: dict[str, str] = Field(default_factory=dict) pause_game: bool = False text_overflow_mode: Literal["overflow", "resize", "truncate"] | None = None - extend: str | None = None - """NOTE: currently this WILL NOT load values from the target book!""" + extend: ResourceLocation | None = None allow_extensions: bool = True - @classmethod - def load(cls, data: dict[str, Any], context: AnyBookContext): - return cls.model_validate(data, context=context) - @classmethod def prepare(cls, props: Properties) -> tuple[dict[str, Any], BookContext]: # read the raw dict from the json file path = props.book_dir / "book.json" - data = load_json(path) - assert isinstance_or_raise(data, dict[str, Any]) + data = load_json_dict(path) - # NOW we can convert the actual book data - return data, { - "i18n": I18n(props, data["i18n"]), + # set up the deserialization context object + assert isinstance_or_raise(data["i18n"], bool) + assert isinstance_or_raise(data["macros"], dict) + context: BookContext = { "props": props, + "i18n": I18n(props, data["i18n"]), "macros": DEFAULT_MACROS | data["macros"], } + return data, context + + @classmethod + def load(cls, data: dict[str, Any], context: AnyBookContext) -> Self: + return cls.model_validate(data, context=context) + + @classmethod + def from_id(cls, book_id: ResourceLocation) -> Self: + # load the module for the given book id using the entry point + # TODO: this is untested because it needs to change for 0.11 anyway :/ + books = entry_points(group="hexdoc.book_data") + book_module = books[str(book_id)].load() + + # read and validate the actual data file + book_path = resources.files(book_module) / book_module.BOOK_DATA_PATH + return cls.model_validate_json(book_path.read_text("utf-8")) + + @model_validator(mode="before") + def _pre_root(cls, data: dict[str, Any], info: ValidationInfo) -> dict[str, Any]: + context = cast(AnyBookContext, info.context) + if not context: + return data + + return data | { + "i18n_data": context["i18n"], + } + @model_validator(mode="after") def _post_root(self, info: ValidationInfo) -> Self: """Loads categories and entries.""" context = cast(AnyBookContext, info.context) if not context: return self - self.context = context - # categories - self.categories = Category.load_all(context) + # load categories + self._categories: dict[ResourceLocation, Category] = Category.load_all(context) - # entries + # load entries for path in context["props"].entries_dir.rglob("*.json"): - # i used the entry to insert the entry (pretty sure thanos said that) entry = Entry.load(path, context) - self.categories[entry.category_id].entries.append(entry) + # i used the entry to insert the entry (pretty sure thanos said that) + self._categories[entry.category_id].entries.append(entry) # we inserted a bunch of entries in no particular order, so sort each category - for category in self.categories.values(): + for category in self._categories.values(): category.entries.sort() return self @@ -120,5 +143,5 @@ class Book(Generic[AnyContext, AnyBookContext], HexDocModel[AnyBookContext]): return self.model if self.index_icon_ is None else self.index_icon_ @property - def props(self) -> Properties: - return self.context["props"] + def categories(self): + return self._categories diff --git a/doc/src/hexdoc/patchouli/text/formatting.py b/doc/src/hexdoc/patchouli/text/formatting.py index 05190099..c3694dca 100644 --- a/doc/src/hexdoc/patchouli/text/formatting.py +++ b/doc/src/hexdoc/patchouli/text/formatting.py @@ -216,10 +216,14 @@ class ParagraphStyle(Style, frozen=True): return out.element("p", **self.attributes) +def is_external_link(value: str) -> bool: + return value.startswith(("https:", "http:")) + + def _format_href(value: str) -> str: - if not value.startswith(("http:", "https:")): - return "#" + value.replace("#", "@") - return value + if is_external_link(value): + return value + return f"#{value.replace('#', '@')}" class FunctionStyle(Style, frozen=True): diff --git a/doc/src/hexdoc/utils/deserialize.py b/doc/src/hexdoc/utils/deserialize.py index 419fe0ff..96d25c1a 100644 --- a/doc/src/hexdoc/utils/deserialize.py +++ b/doc/src/hexdoc/utils/deserialize.py @@ -38,7 +38,7 @@ JSONDict = dict[str, "JSONValue"] JSONValue = JSONDict | list["JSONValue"] | str | int | float | bool | None -def load_json(path: Path) -> JSONDict: +def load_json_dict(path: Path) -> JSONDict: data: JSONValue = json.loads(path.read_text("utf-8")) assert isinstance_or_raise(data, dict) return data diff --git a/doc/src/hexdoc/utils/model.py b/doc/src/hexdoc/utils/model.py index 33553e5a..c954e786 100644 --- a/doc/src/hexdoc/utils/model.py +++ b/doc/src/hexdoc/utils/model.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, dataclass_transfo from pydantic import BaseModel, ConfigDict from typing_extensions import TypedDict -from .deserialize import load_json +from .deserialize import load_json_dict if TYPE_CHECKING: from pydantic.root_model import Model @@ -56,5 +56,5 @@ class FrozenHexDocModel(Generic[AnyContext], HexDocModel[AnyContext]): class HexDocFileModel(HexDocModel[AnyContext]): @classmethod def load(cls, path: Path, context: AnyContext) -> Self: - data = load_json(path) | {"__path": path} + data = load_json_dict(path) | {"__path": path} return cls.model_validate(data, context=context) diff --git a/doc/src/hexdoc/utils/properties.py b/doc/src/hexdoc/utils/properties.py index aa35aeb1..ce0e3776 100644 --- a/doc/src/hexdoc/utils/properties.py +++ b/doc/src/hexdoc/utils/properties.py @@ -33,7 +33,7 @@ class PlatformProps(HexDocModel[Any]): class I18nProps(HexDocModel[Any]): - lang: str + default_lang: str filename: str extra: dict[str, str] | None = None @@ -78,7 +78,7 @@ class Properties(HexDocModel[Any]): @property def lang(self): - return self.i18n.lang + return self.i18n.default_lang @property def book_dir(self) -> Path: diff --git a/doc/src/hexdoc/utils/resource.py b/doc/src/hexdoc/utils/resource.py index c6e34525..9ba84f30 100644 --- a/doc/src/hexdoc/utils/resource.py +++ b/doc/src/hexdoc/utils/resource.py @@ -35,12 +35,16 @@ class BaseResourceLocation: cls._from_str_regex = regex @classmethod - def from_str(cls, raw: str) -> Self: + def from_str(cls, raw: str, default_namespace: str | None = None) -> Self: match = cls._from_str_regex.fullmatch(raw) if match is None: raise ValueError(f"Invalid {cls.__name__} string: {raw}") - return cls(**match.groupdict()) + groups = match.groupdict() + if not groups.get("namespace") and default_namespace is not None: + groups["namespace"] = default_namespace + + return cls(**groups) @model_validator(mode="wrap") @classmethod