diff --git a/doc/properties.toml b/doc/properties.toml index 161f358d..c747ca59 100644 --- a/doc/properties.toml +++ b/doc/properties.toml @@ -3,6 +3,7 @@ modid = "hexcasting" book_name = "thehexbook" template = "template.html" +is_0_black = false recipe_dirs = [ "{fabric.generated}/data/{modid}/recipes", "{forge.generated}/data/{modid}/recipes", @@ -13,6 +14,9 @@ default_recipe_dir = 0 # more on that later pattern_regex = {_Raw='HexPattern\.fromAngles\("([qweasd]+)", HexDir\.(\w+)\),\s*modLoc\("([^"]+)"\)([^;]*true\);)?'} +[base_asset_urls] +hexcasting = "https://raw.githubusercontent.com/gamma-delta/HexMod/main/Common/src/main/resources" + [i18n] lang = "en_us" filename = "{lang}.json" @@ -23,7 +27,6 @@ filename = "{lang}.json" # platforms -# fabric and forge are optional [common] src = "../Common/src" diff --git a/doc/src/common/properties.py b/doc/src/common/properties.py index c396b373..ed1279fd 100644 --- a/doc/src/common/properties.py +++ b/doc/src/common/properties.py @@ -1,13 +1,25 @@ import re from pathlib import Path -from typing import Any, Self +from typing import Annotated, Any, Self -from pydantic import Field, model_validator +from pydantic import ( + AfterValidator, + Field, + FieldValidationInfo, + HttpUrl, + field_validator, +) from common.model import HexDocModel from common.toml_placeholders import load_toml from hexcasting.pattern import PatternStubFile +NoTrailingSlashHttpUrl = Annotated[ + str, + HttpUrl, + AfterValidator(lambda u: str(u).rstrip("/")), +] + class PlatformProps(HexDocModel[Any]): resources: Path @@ -27,15 +39,17 @@ class Properties(HexDocModel[Any]): modid: str book_name: str template: Path + is_0_black: bool + """If true, the style `$(0)` changes the text color to black; otherwise it resets + the text color to the default.""" recipe_dirs: list[Path] default_recipe_dir_index_: int = Field(alias="default_recipe_dir") pattern_regex: re.Pattern[str] - is_0_black: bool = False - """If true, the style `$(0)` changes the text color to black; otherwise it resets - the text color to the default.""" + base_asset_urls: dict[str, NoTrailingSlashHttpUrl] + """Mapping from modid to the url of that mod's `resources` directory on GitHub.""" i18n: I18nProps @@ -100,10 +114,11 @@ class Properties(HexDocModel[Any]): for stub in platform.pattern_stubs ] - @model_validator(mode="after") - def _check_default_recipe_dir(self): - if self.default_recipe_dir_index_ >= len(self.recipe_dirs): + @field_validator("default_recipe_dir_index_") + def _check_default_recipe_dir(cls, value: int, info: FieldValidationInfo) -> int: + num_dirs = len(info.data["recipe_dirs"]) + if value >= num_dirs: raise ValueError( - f"default_recipe_dir must be a valid index of recipe_dirs (expected <={len(self.recipe_dirs)}, got {self.default_recipe_dir_index_})" + f"default_recipe_dir must be a valid index of recipe_dirs (expected <={num_dirs - 1}, got {value})" ) - return self + return value diff --git a/doc/src/hexcasting/abstract_hex_pages.py b/doc/src/hexcasting/abstract_hex_pages.py index 4cdc0a59..e7028360 100644 --- a/doc/src/hexcasting/abstract_hex_pages.py +++ b/doc/src/hexcasting/abstract_hex_pages.py @@ -11,7 +11,7 @@ from patchouli.page import PageWithTitle from .hex_book import AnyHexContext, HexContext -# TODO: make anchor required +# TODO: make anchor required (breaks because of Greater Sentinel) class PageWithPattern(PageWithTitle[AnyHexContext], ABC, type=None): title_: None = Field(default=None, include=True) diff --git a/doc/src/hexcasting/scripts/collate_data.py b/doc/src/hexcasting/scripts/collate_data.py index 7699ce0d..1a784475 100644 --- a/doc/src/hexcasting/scripts/collate_data.py +++ b/doc/src/hexcasting/scripts/collate_data.py @@ -2,6 +2,7 @@ import io from typing import Any +from common.properties import Properties from hexcasting import HexBook from hexcasting.abstract_hex_pages import PageWithPattern from hexcasting.hex_pages import BrainsweepPage, CraftingMultiPage, LookupPatternPage @@ -17,13 +18,7 @@ from patchouli.page import ( SpotlightPage, TextPage, ) -from patchouli.text.tags import Stream - -# extra info :( -# TODO: properties.toml -repo_names = { - "hexcasting": "https://raw.githubusercontent.com/gamma-delta/HexMod/main/Common/src/main/resources", -} +from patchouli.text.html import HTMLStream def entry_spoilered(root_info: HexBook, entry: Entry): @@ -36,42 +31,42 @@ def category_spoilered(root_info: HexBook, category: Category): return all(entry_spoilered(root_info, ent) for ent in category.entries) -def write_block(out: Stream, block: FormatTree | str | LocalizedStr): +def write_block(out: HTMLStream, block: FormatTree | str | LocalizedStr): if isinstance(block, LocalizedStr): block = str(block) if isinstance(block, str): first = False for line in block.split("\n"): if first: - out.tag("br") + out.self_closing_element("br") first = True out.text(line) return - with block.style.tag(out): + with block.style.element(out): for child in block.children: write_block(out, child) -def anchor_toc(out: Stream): - with out.pair_tag( +def anchor_toc(out: HTMLStream): + with out.element( "a", href="#table-of-contents", clazz="permalink small", title="Jump to top" ): - out.empty_pair_tag("i", clazz="bi bi-box-arrow-up") + out.empty_element("i", clazz="bi bi-box-arrow-up") -def permalink(out: Stream, link: str): - with out.pair_tag("a", href=link, clazz="permalink small", title="Permalink"): - out.empty_pair_tag("i", clazz="bi bi-link-45deg") +def permalink(out: HTMLStream, link: str): + with out.element("a", href=link, clazz="permalink small", title="Permalink"): + out.empty_element("i", clazz="bi bi-link-45deg") -def write_page(out: Stream, pageid: str, page: Page[Any]): +def write_page(out: HTMLStream, pageid: str, page: Page[Any], props: Properties): if anchor := page.anchor: anchor_id = pageid + "@" + anchor else: anchor_id = None # TODO: put this in the page classes - this is just a stopgap to make the tests pass - with out.pair_tag_if(anchor_id, "div", id=anchor_id): + with out.element_if(anchor_id, "div", id=anchor_id): if isinstance(page, PageWithTitle) and page.title is not None: # gross _kwargs = ( @@ -79,7 +74,7 @@ def write_page(out: Stream, pageid: str, page: Page[Any]): if isinstance(page, LookupPatternPage) else {} ) - with out.pair_tag("h4", **_kwargs): + with out.element("h4", **_kwargs): out.text(page.title) if anchor_id: permalink(out, "#" + anchor_id) @@ -89,69 +84,69 @@ def write_page(out: Stream, pageid: str, page: Page[Any]): pass case LinkPage(): write_block(out, page.text) - with out.pair_tag("h4", clazz="linkout"): - with out.pair_tag("a", href=page.url): + with out.element("h4", clazz="linkout"): + with out.element("a", href=page.url): out.text(page.link_text) case TextPage(): # LinkPage is a TextPage, so this needs to be below it write_block(out, page.text) case SpotlightPage(): - with out.pair_tag("h4", clazz="spotlight-title page-header"): + with out.element("h4", clazz="spotlight-title page-header"): out.text(page.item) if page.text is not None: write_block(out, page.text) case CraftingPage(): - with out.pair_tag("blockquote", clazz="crafting-info"): + with out.element("blockquote", clazz="crafting-info"): out.text(f"Depicted in the book: The crafting recipe for the ") first = True for recipe in page.recipes: if not first: out.text(" and ") first = False - with out.pair_tag("code"): + with out.element("code"): out.text(recipe.result.item) out.text(".") if page.text is not None: write_block(out, page.text) case ImagePage(): - with out.pair_tag("p", clazz="img-wrapper"): + with out.element("p", clazz="img-wrapper"): for img in page.images: # TODO: make a thing for this - out.empty_pair_tag( + out.empty_element( "img", - src=f"{repo_names[img.namespace]}/assets/{img.namespace}/{img.path}", + src=f"{props.base_asset_urls[img.namespace]}/assets/{img.namespace}/{img.path}", ) if page.text is not None: write_block(out, page.text) case CraftingMultiPage(): - with out.pair_tag("blockquote", clazz="crafting-info"): + with out.element("blockquote", clazz="crafting-info"): out.text( f"Depicted in the book: Several crafting recipes, for the " ) - with out.pair_tag("code"): + with out.element("code"): out.text(page.recipes[0].result.item) for i in page.recipes[1:]: out.text(", ") - with out.pair_tag("code"): + with out.element("code"): out.text(i.result.item) out.text(".") if page.text is not None: write_block(out, page.text) case BrainsweepPage(): - with out.pair_tag("blockquote", clazz="crafting-info"): + with out.element("blockquote", clazz="crafting-info"): out.text( f"Depicted in the book: A mind-flaying recipe producing the " ) - with out.pair_tag("code"): + with out.element("code"): out.text(page.recipe.result.name) out.text(".") if page.text is not None: write_block(out, page.text) case PageWithPattern(): - with out.pair_tag("details", clazz="spell-collapsible"): - out.empty_pair_tag("summary", clazz="collapse-spell") + with out.element("details", clazz="spell-collapsible"): + out.empty_element("summary", clazz="collapse-spell") for pattern in page.patterns: - with out.pair_tag( + with out.element( "canvas", clazz="spell-viz", width=216, @@ -166,30 +161,30 @@ def write_page(out: Stream, pageid: str, page: Page[Any]): ) write_block(out, page.text) case _: - with out.pair_tag("p", clazz="todo-note"): + with out.element("p", clazz="todo-note"): out.text(f"TODO: Missing processor for type: {type(page)}") if isinstance(page, PageWithText): write_block(out, page.text) - out.tag("br") + out.self_closing_element("br") -def write_entry(out: Stream, book: HexBook, entry: Entry): - with out.pair_tag("div", id=entry.id.path): - with out.pair_tag_if(entry_spoilered(book, entry), "div", clazz="spoilered"): - with out.pair_tag("h3", clazz="entry-title page-header"): +def write_entry(out: HTMLStream, book: HexBook, entry: Entry): + with out.element("div", id=entry.id.path): + with out.element_if(entry_spoilered(book, entry), "div", clazz="spoilered"): + with out.element("h3", clazz="entry-title page-header"): write_block(out, entry.name) anchor_toc(out) permalink(out, entry.id.href) for page in entry.pages: - write_page(out, entry.id.path, page) + write_page(out, entry.id.path, page, book.context["props"]) -def write_category(out: Stream, book: HexBook, category: Category): - with out.pair_tag("section", id=category.id.path): - with out.pair_tag_if( +def write_category(out: HTMLStream, book: HexBook, category: Category): + with out.element("section", id=category.id.path): + with out.element_if( category_spoilered(book, category), "div", clazz="spoilered" ): - with out.pair_tag("h2", clazz="category-title page-header"): + with out.element("h2", clazz="category-title page-header"): write_block(out, category.name) anchor_toc(out) permalink(out, category.id.href) @@ -199,31 +194,31 @@ def write_category(out: Stream, book: HexBook, category: Category): write_entry(out, book, entry) -def write_toc(out: Stream, book: HexBook): - with out.pair_tag("h2", id="table-of-contents", clazz="page-header"): +def write_toc(out: HTMLStream, book: HexBook): + with out.element("h2", id="table-of-contents", clazz="page-header"): out.text("Table of Contents") - with out.pair_tag( + with out.element( "a", href="javascript:void(0)", clazz="permalink toggle-link small", data_target="toc-category", title="Toggle all", ): - out.empty_pair_tag("i", clazz="bi bi-list-nested") + out.empty_element("i", clazz="bi bi-list-nested") permalink(out, "#table-of-contents") for category in book.categories.values(): - with out.pair_tag("details", clazz="toc-category"): - with out.pair_tag("summary"): - with out.pair_tag( + with out.element("details", clazz="toc-category"): + with out.element("summary"): + with out.element( "a", href=category.id.href, clazz="spoilered" if category_spoilered(book, category) else "", ): out.text(category.name) - with out.pair_tag("ul"): + with out.element("ul"): for entry in category.entries: - with out.pair_tag("li"): - with out.pair_tag( + with out.element("li"): + with out.element( "a", href=entry.id.href, clazz="spoilered" if entry_spoilered(book, entry) else "", @@ -231,15 +226,15 @@ def write_toc(out: Stream, book: HexBook): out.text(entry.name) -def write_book(out: Stream, book: HexBook): - with out.pair_tag("div", clazz="container"): - with out.pair_tag("header", clazz="jumbotron"): - with out.pair_tag("h1", clazz="book-title"): +def write_book(out: HTMLStream, book: HexBook): + with out.element("div", clazz="container"): + with out.element("header", clazz="jumbotron"): + with out.element("h1", clazz="book-title"): write_block(out, book.name) write_block(out, book.landing_text) - with out.pair_tag("nav"): + with out.element("nav"): write_toc(out, book) - with out.pair_tag("main", clazz="book-body"): + with out.element("main", clazz="book-body"): for category in book.categories.values(): write_category(out, book, category) @@ -258,7 +253,7 @@ def generate_docs(book: HexBook, template: str) -> str: _, *spoilers = line.split() book.context["spoilers"].update(spoilers) elif line == "#DUMP_BODY_HERE\n": - write_book(Stream(output), book) + write_book(HTMLStream(output), book) print("", file=output) else: print(line, end="", file=output) diff --git a/doc/src/patchouli/text/formatting.py b/doc/src/patchouli/text/formatting.py index 37ea71da..9f314f9e 100644 --- a/doc/src/patchouli/text/formatting.py +++ b/doc/src/patchouli/text/formatting.py @@ -17,7 +17,7 @@ from common.properties import Properties from common.types import TryGetEnum from minecraft.i18n import I18nContext, LocalizedStr -from .tags import PairTag, Stream +from .html import HTMLElement, HTMLStream DEFAULT_MACROS = { "$(obf)": "$(k)", @@ -167,27 +167,27 @@ class Style(ABC, HexDocModel[Any], frozen=True): raise ValueError(f"Unhandled style: {style_str}") @abstractmethod - def tag(self, out: Stream) -> PairTag | nullcontext[None]: + def element(self, out: HTMLStream) -> HTMLElement | nullcontext[None]: ... class CommandStyle(Style, frozen=True): type: CommandStyleType | BaseStyleType - def tag(self, out: Stream) -> PairTag | nullcontext[None]: + def element(self, out: HTMLStream) -> HTMLElement | nullcontext[None]: match self.type: case CommandStyleType.obfuscated: - return out.pair_tag("span", clazz="obfuscated") + return out.element("span", clazz="obfuscated") case CommandStyleType.bold: - return out.pair_tag("strong") + return out.element("strong") case CommandStyleType.strikethrough: - return out.pair_tag("s") + return out.element("s") case CommandStyleType.underline: - return out.pair_tag("span", style="text-decoration: underline") + return out.element("span", style="text-decoration: underline") case CommandStyleType.italic: - return out.pair_tag("i") + return out.element("i") case SpecialStyleType.base: - return out.null_tag() + return nullcontext() class ParagraphStyle(Style, frozen=True): @@ -212,8 +212,8 @@ class ParagraphStyle(Style, frozen=True): def list_item(cls) -> Self: return cls(attributes={"clazz": "fake-li"}) - def tag(self, out: Stream) -> PairTag: - return out.pair_tag("p", **self.attributes) + def element(self, out: HTMLStream) -> HTMLElement: + return out.element("p", **self.attributes) def _format_href(value: str) -> str: @@ -226,20 +226,20 @@ class FunctionStyle(Style, frozen=True): type: FunctionStyleType | ColorStyleType value: str - def tag(self, out: Stream) -> PairTag: + def element(self, out: HTMLStream) -> HTMLElement: match self.type: case FunctionStyleType.link: - return out.pair_tag("a", href=_format_href(self.value)) + return out.element("a", href=_format_href(self.value)) case FunctionStyleType.tooltip: - return out.pair_tag("span", clazz="has-tooltip", title=self.value) + return out.element("span", clazz="has-tooltip", title=self.value) case FunctionStyleType.cmd_click: - return out.pair_tag( + return out.element( "span", clazz="has-cmd_click", title=f"When clicked, would execute: {self.value}", ) case SpecialStyleType.color: - return out.pair_tag("span", style=f"color: #{self.value}") + return out.element("span", style=f"color: #{self.value}") # intentionally not inheriting from Style, because this is basically an implementation diff --git a/doc/src/patchouli/text/html.py b/doc/src/patchouli/text/html.py new file mode 100644 index 00000000..2dd3dfaf --- /dev/null +++ b/doc/src/patchouli/text/html.py @@ -0,0 +1,51 @@ +# TODO: type +from contextlib import nullcontext +from dataclasses import dataclass +from html import escape +from typing import IO, Any + +from minecraft.i18n import LocalizedStr + + +def attributes_to_str(attributes: dict[str, Any]): + return "".join( + f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}" + for key, value in attributes.items() + ) + + +@dataclass +class HTMLElement: + stream: IO[str] + name: str + attributes: dict[str, Any] + + def __enter__(self) -> None: + self.stream.write(f"<{self.name}{attributes_to_str(self.attributes)}>") + + def __exit__(self, *_: Any) -> None: + self.stream.write(f"") + + +@dataclass +class HTMLStream: + stream: IO[str] + + def self_closing_element(self, name: str, **kwargs: Any): + keywords = attributes_to_str(kwargs) + self.stream.write(f"<{name}{keywords} />") + return self + + def element(self, name: str, **kwargs: Any): + return HTMLElement(self.stream, name, kwargs) + + def element_if(self, cond: Any, name: str, **kwargs: Any): + return self.element(name, **kwargs) if cond else nullcontext() + + def empty_element(self, name: str, **kwargs: Any): + with self.element(name, **kwargs): + pass + + def text(self, txt: str | LocalizedStr): + self.stream.write(escape(str(txt))) + return self diff --git a/doc/src/patchouli/text/tags.py b/doc/src/patchouli/text/tags.py deleted file mode 100644 index 453bbeac..00000000 --- a/doc/src/patchouli/text/tags.py +++ /dev/null @@ -1,68 +0,0 @@ -# TODO: type -from contextlib import nullcontext -from dataclasses import InitVar, dataclass -from html import escape -from typing import IO, Any - -from minecraft.i18n import LocalizedStr - - -def tag_args(kwargs: dict[str, Any]): - return "".join( - f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}" - for key, value in kwargs.items() - ) - - -@dataclass -class PairTag: - stream: IO[str] - name: str - args: InitVar[dict[str, Any]] - - def __post_init__(self, args: dict[str, Any]): - self.args_str = tag_args(args) - - def __enter__(self): - # TODO: self.stream.write?????????? - print(f"<{self.name}{self.args_str}>", file=self.stream, end="") - - def __exit__(self, _1: Any, _2: Any, _3: Any): - print(f"", file=self.stream, end="") - - -class Empty: - def __enter__(self): - pass - - def __exit__(self, _1: Any, _2: Any, _3: Any): - pass - - -class Stream: - __slots__ = ["stream"] - - def __init__(self, stream: IO[str]): - self.stream = stream - - def tag(self, name: str, **kwargs: Any): - keywords = tag_args(kwargs) - print(f"<{name}{keywords} />", file=self.stream, end="") - return self - - def pair_tag(self, name: str, **kwargs: Any): - return PairTag(self.stream, name, kwargs) - - def pair_tag_if(self, cond: Any, name: str, **kwargs: Any): - return self.pair_tag(name, **kwargs) if cond else Empty() - - def empty_pair_tag(self, name: str, **kwargs: Any): - with self.pair_tag(name, **kwargs): - pass - - def null_tag(self): - return nullcontext() - - def text(self, txt: str | LocalizedStr): - print(escape(str(txt)), file=self.stream, end="") - return self