hexdoc.cli.app
1import code 2import logging 3import os 4import shutil 5import sys 6from dataclasses import dataclass 7from http.server import HTTPServer, SimpleHTTPRequestHandler 8from pathlib import Path 9from textwrap import dedent 10from typing import Annotated, Any, Optional 11 12import typer 13from packaging.version import Version 14from typer import Argument, Option 15from yarl import URL 16 17from hexdoc.__version__ import VERSION 18from hexdoc.core import ModResourceLoader, ResourceLocation 19from hexdoc.data.metadata import HexdocMetadata 20from hexdoc.data.sitemap import ( 21 delete_updated_books, 22 dump_sitemap, 23 load_sitemap, 24) 25from hexdoc.graphics.render import BlockRenderer, DebugType 26from hexdoc.jinja.render import create_jinja_env, get_templates, render_book 27from hexdoc.minecraft import I18n 28from hexdoc.minecraft.assets import ( 29 AnimatedTexture, 30 PNGTexture, 31 TextureContext, 32) 33from hexdoc.minecraft.assets.load_assets import render_block 34from hexdoc.minecraft.models.item import ItemModel 35from hexdoc.minecraft.models.load import load_model 36from hexdoc.patchouli import BookContext, FormattingContext 37from hexdoc.plugin import ModPluginWithBook 38from hexdoc.utils import git_root, setup_logging, write_to_path 39from hexdoc.utils.logging import repl_readfunc 40 41from . import ci 42from .utils.args import ( 43 DEFAULT_MERGE_DST, 44 DEFAULT_MERGE_SRC, 45 BranchOption, 46 DefaultTyper, 47 PathArgument, 48 PropsOption, 49 ReleaseOption, 50 VerbosityOption, 51) 52from .utils.load import ( 53 init_context, 54 load_common_data, 55 render_textures_and_export_metadata, 56) 57 58logger = logging.getLogger(__name__) 59 60 61def set_default_env(): 62 """Sets placeholder values for unneeded environment variables.""" 63 for key, value in { 64 "GITHUB_REPOSITORY": "placeholder/placeholder", 65 "GITHUB_SHA": "", 66 "GITHUB_PAGES_URL": "", 67 }.items(): 68 os.environ.setdefault(key, value) 69 70 71@dataclass(kw_only=True) 72class LoadedBookInfo: 73 language: str 74 i18n: I18n 75 context: dict[str, Any] 76 book_id: ResourceLocation 77 book: Any 78 79 80app = DefaultTyper() 81app.add_typer(ci.app) 82 83 84def version_callback(value: bool): 85 if value: 86 print(f"hexdoc {VERSION}") 87 raise typer.Exit() 88 89 90@app.callback() 91def callback( 92 verbosity: VerbosityOption = 0, 93 quiet_lang: Optional[list[str]] = None, 94 version: Annotated[ 95 bool, 96 Option("--version", "-V", callback=version_callback, is_eager=True), 97 ] = False, 98): 99 if quiet_lang: 100 logger.warning( 101 "`--quiet-lang` is deprecated, use `props.lang.{lang}.quiet` instead." 102 ) 103 setup_logging(verbosity, ci=False, quiet_langs=quiet_lang) 104 105 106@app.command() 107def repl(*, props_file: PropsOption): 108 """Start a Python shell with some helpful extra locals added from hexdoc.""" 109 110 repl_locals = dict[str, Any]( 111 props_path=props_file, 112 ) 113 114 try: 115 props, pm, book_plugin, plugin = load_common_data(props_file, branch="") 116 repl_locals |= dict( 117 props=props, 118 pm=pm, 119 plugin=plugin, 120 ) 121 122 loader = ModResourceLoader.load_all( 123 props, 124 pm, 125 export=False, 126 ) 127 repl_locals["loader"] = loader 128 129 if props.book_id: 130 book_id, book_data = book_plugin.load_book_data(props.book_id, loader) 131 else: 132 book_id = None 133 book_data = {} 134 135 i18n = I18n.load_all( 136 loader, 137 enabled=book_plugin.is_i18n_enabled(book_data), 138 )[props.default_lang] 139 140 all_metadata = loader.load_metadata(model_type=HexdocMetadata) 141 repl_locals["all_metadata"] = all_metadata 142 143 if book_id and book_data: 144 context = init_context( 145 book_id=book_id, 146 book_data=book_data, 147 pm=pm, 148 loader=loader, 149 i18n=i18n, 150 all_metadata=all_metadata, 151 ) 152 book = book_plugin.validate_book(book_data, context=context) 153 repl_locals |= dict( 154 book=book, 155 context=context, 156 ) 157 except Exception as e: 158 print(e) 159 160 code.interact( 161 banner=dedent( 162 f"""\ 163 [hexdoc repl] Python {sys.version} 164 Locals: {', '.join(sorted(repl_locals.keys()))}""" 165 ), 166 readfunc=repl_readfunc(), 167 local=repl_locals, 168 exitmsg="", 169 ) 170 171 172@app.command() 173def build( 174 output_dir: PathArgument = DEFAULT_MERGE_SRC, 175 *, 176 branch: BranchOption, 177 release: ReleaseOption = False, 178 clean: bool = False, 179 props_file: PropsOption, 180) -> Path: 181 """Export resources and render the web book. 182 183 For developers: returns the site path (eg. `/v/latest/main`). 184 """ 185 186 props, pm, book_plugin, plugin = load_common_data(props_file, branch) 187 188 if props.env.hexdoc_subdirectory: 189 output_dir /= props.env.hexdoc_subdirectory 190 191 logger.info("Exporting resources.") 192 with ModResourceLoader.clean_and_load_all(props, pm, export=True) as loader: 193 site_path = plugin.site_path(versioned=release) 194 site_dir = output_dir / site_path 195 196 asset_loader = plugin.asset_loader( 197 loader=loader, 198 site_url=props.env.github_pages_url.joinpath(*site_path.parts), 199 asset_url=props.env.asset_url, 200 render_dir=site_dir, 201 ) 202 203 all_metadata = render_textures_and_export_metadata(loader, asset_loader) 204 205 if not props.book_id: 206 logger.info("Skipping book load because props.book_id is not set.") 207 return site_dir 208 209 book_id, book_data = book_plugin.load_book_data(props.book_id, loader) 210 211 all_i18n = I18n.load_all( 212 loader, 213 enabled=book_plugin.is_i18n_enabled(book_data), 214 ) 215 216 logger.info("Loading books for all languages.") 217 books = list[LoadedBookInfo]() 218 for language, i18n in all_i18n.items(): 219 try: 220 context = init_context( 221 book_id=book_id, 222 book_data=book_data, 223 pm=pm, 224 loader=loader, 225 i18n=i18n, 226 all_metadata=all_metadata, 227 ) 228 book = book_plugin.validate_book(book_data, context=context) 229 books.append( 230 LoadedBookInfo( 231 language=language, 232 i18n=i18n, 233 context=context, 234 book_id=book_id, 235 book=book, 236 ) 237 ) 238 except Exception: 239 if not props.lang[language].ignore_errors: 240 raise 241 logger.exception(f"Failed to load book for {language}") 242 243 if not props.template: 244 logger.info("Skipping book render because props.template is not set.") 245 return site_dir 246 247 if not isinstance(plugin, ModPluginWithBook): 248 raise ValueError( 249 f"ModPlugin registered for modid `{props.modid}` (from props.modid)" 250 f" does not inherit from ModPluginWithBook: {plugin}" 251 ) 252 253 logger.info("Setting up Jinja template environment.") 254 env = create_jinja_env(pm, props.template.include, props_file) 255 256 logger.info(f"Rendering book for {len(books)} language(s).") 257 for book_info in books: 258 try: 259 templates = get_templates( 260 props=props, 261 pm=pm, 262 book=book_info.book, 263 context=book_info.context, 264 env=env, 265 ) 266 if not templates: 267 raise RuntimeError( 268 "No templates to render, check your props.template configuration " 269 f"(in {props_file.as_posix()})" 270 ) 271 272 book_ctx = BookContext.of(book_info.context) 273 formatting_ctx = FormattingContext.of(book_info.context) 274 texture_ctx = TextureContext.of(book_info.context) 275 276 site_book_path = plugin.site_book_path( 277 book_info.language, 278 versioned=release, 279 ) 280 if clean: 281 shutil.rmtree(output_dir / site_book_path, ignore_errors=True) 282 283 template_args: dict[str, Any] = book_info.context | { 284 "all_metadata": all_metadata, 285 "png_textures": PNGTexture.get_lookup(texture_ctx.textures), 286 "animations": sorted( # this MUST be sorted to avoid flaky tests 287 AnimatedTexture.get_lookup(texture_ctx.textures).values(), 288 key=lambda t: t.css_class, 289 ), 290 "book": book_info.book, 291 "book_links": book_ctx.book_links, 292 } 293 294 render_book( 295 props=props, 296 pm=pm, 297 plugin=plugin, 298 lang=book_info.language, 299 book_id=book_info.book_id, 300 i18n=book_info.i18n, 301 macros=formatting_ctx.macros, 302 env=env, 303 templates=templates, 304 output_dir=output_dir, 305 version=plugin.mod_version if release else f"latest/{branch}", 306 site_path=site_book_path, 307 versioned=release, 308 template_args=template_args, 309 ) 310 except Exception: 311 if not props.lang[book_info.language].ignore_errors: 312 raise 313 logger.exception(f"Failed to render book for {book_info.language}") 314 315 logger.info("Done.") 316 return site_dir 317 318 319@app.command() 320def merge( 321 *, 322 props_file: PropsOption, 323 src: Path = DEFAULT_MERGE_SRC, 324 dst: Path = DEFAULT_MERGE_DST, 325 release: ReleaseOption = False, 326): 327 props, _, _, plugin = load_common_data(props_file, branch="", book=True) 328 if not props.template: 329 raise ValueError("Expected a value for props.template, got None") 330 331 if props.env.hexdoc_subdirectory: 332 src /= props.env.hexdoc_subdirectory 333 dst /= props.env.hexdoc_subdirectory 334 335 dst.mkdir(parents=True, exist_ok=True) 336 337 # remove any stale data that we're about to replace 338 delete_updated_books(src=src, dst=dst, release=release) 339 340 # do the merge 341 shutil.copytree(src=src, dst=dst, dirs_exist_ok=True) 342 343 # rebuild the sitemap 344 sitemap, minecraft_sitemap = load_sitemap(dst) 345 dump_sitemap(dst, sitemap, minecraft_sitemap) 346 347 # find paths for redirect pages 348 redirects = dict[Path, str]() 349 350 root_version: Version | None = None 351 root_redirect: str | None = None 352 353 for version, item in sitemap.items(): 354 if version.startswith("latest"): # TODO: check type of item instead 355 continue 356 357 redirects[plugin.site_root / version] = item.default_marker.redirect_contents 358 for lang, marker in item.markers.items(): 359 redirects[plugin.site_root / version / lang] = marker.redirect_contents 360 361 item_version = Version(version) 362 if not root_version or item_version > root_version: 363 root_version = item_version 364 root_redirect = item.default_marker.redirect_contents 365 366 if root_redirect is None: 367 # TODO: use plugin to build this path 368 # TODO: refactor 369 if item := sitemap.get(f"latest/{props.default_branch}"): 370 root_redirect = item.default_marker.redirect_contents 371 elif sitemap: 372 key = sorted(sitemap.keys())[0] 373 root_redirect = sitemap[key].default_marker.redirect_contents 374 logger.warning( 375 f"No book exists for the default branch `{props.default_branch}`, generating root redirect to `{key}` (check the value of `default_branch` in hexdoc.toml)" 376 ) 377 else: 378 logger.error("No books found, skipping root redirect") 379 380 if root_redirect is not None: 381 redirects[Path()] = root_redirect 382 383 # write redirect pages 384 if props.template.redirect: 385 filename, _ = props.template.redirect 386 for path, redirect_contents in redirects.items(): 387 write_to_path(dst / path / filename, redirect_contents) 388 389 # bypass Jekyll on GitHub Pages 390 (dst / ".nojekyll").touch() 391 392 393@app.command() 394def serve( 395 *, 396 props_file: PropsOption, 397 port: int = 8000, 398 src: Path = DEFAULT_MERGE_SRC, 399 dst: Path = DEFAULT_MERGE_DST, 400 branch: BranchOption, 401 release: bool = True, 402 clean: bool = False, 403 do_merge: Annotated[bool, Option("--merge/--no-merge")] = True, 404): 405 book_root = dst 406 relative_root = book_root.resolve().relative_to(Path.cwd()) 407 408 base_url = URL.build(scheme="http", host="localhost", port=port) 409 book_url = base_url.joinpath(*relative_root.parts) 410 411 repo_root = git_root(props_file.parent) 412 asset_root = repo_root.relative_to(Path.cwd()) 413 414 os.environ |= { 415 # prepend a slash to the path so it can find the texture in the local repo 416 # eg. http://localhost:8000/_site/src/docs/Common/... 417 # vs. http://localhost:8000/Common/... 418 "DEBUG_GITHUBUSERCONTENT": str(base_url.joinpath(*asset_root.parts)), 419 "GITHUB_PAGES_URL": str(book_url), 420 } 421 422 print() 423 logger.info(f"hexdoc build --{'' if release else 'no-'}release") 424 build( 425 branch=branch, 426 props_file=props_file, 427 output_dir=src, 428 release=True, 429 clean=clean, 430 ) 431 432 if do_merge: 433 print() 434 logger.info("hexdoc merge") 435 merge( 436 src=src, 437 dst=dst, 438 props_file=props_file, 439 ) 440 441 print() 442 logger.info(f"Serving web book at {book_url} (press ctrl+c to exit)\n") 443 with HTTPServer(("", port), SimpleHTTPRequestHandler) as httpd: 444 # ignore KeyboardInterrupt to stop Typer from printing "Aborted." 445 # because it keeps printing after nodemon exits and breaking the output 446 try: 447 httpd.serve_forever() 448 except KeyboardInterrupt: 449 pass 450 451 452@app.command() 453def render_model( 454 model_id: str, 455 *, 456 props_file: PropsOption, 457 output_path: Annotated[Path, Option("--output", "-o")] = Path("out.png"), 458 axes: bool = False, 459 normals: bool = False, 460): 461 """Use hexdoc's block rendering to render an item or block model.""" 462 set_default_env() 463 props, pm, *_ = load_common_data(props_file, branch="") 464 465 debug = DebugType.NONE 466 if axes: 467 debug |= DebugType.AXES 468 if normals: 469 debug |= DebugType.NORMALS 470 471 with ModResourceLoader.load_all(props, pm, export=False) as loader: 472 _, model = load_model(loader, ResourceLocation.from_str(model_id)) 473 while isinstance(model, ItemModel) and model.parent: 474 _, model = load_model(loader, model.parent) 475 476 if isinstance(model, ItemModel): 477 raise ValueError(f"Invalid block id: {model_id}") 478 479 with BlockRenderer(loader=loader, debug=debug) as renderer: 480 renderer.render_block_model(model, output_path) 481 482 483@app.command() 484def render_models( 485 model_ids: Annotated[Optional[list[str]], Argument()] = None, 486 *, 487 props_file: PropsOption, 488 render_all: Annotated[bool, Option("--all")] = False, 489 output_dir: Annotated[Path, Option("--output", "-o")] = Path("out"), 490 site_url_str: Annotated[Optional[str], Option("--site-url")] = None, 491 export_resources: bool = True, 492): 493 if not (model_ids or render_all): 494 raise ValueError("At least one model id must be provided if --all is missing") 495 496 site_url = URL(site_url_str or "") 497 498 set_default_env() 499 props, pm, _, plugin = load_common_data(props_file, branch="") 500 501 with ModResourceLoader.load_all(props, pm, export=export_resources) as loader: 502 if model_ids: 503 with BlockRenderer(loader=loader, output_dir=output_dir) as renderer: 504 for model_id in model_ids: 505 model_id = ResourceLocation.from_str(model_id) 506 render_block(model_id, renderer, site_url) 507 else: 508 asset_loader = plugin.asset_loader( 509 loader=loader, 510 site_url=site_url, 511 asset_url=props.env.asset_url, 512 render_dir=output_dir, 513 ) 514 render_textures_and_export_metadata(loader, asset_loader) 515 516 logger.info("Done.") 517 518 519@app.command(deprecated=True) 520def export( 521 output_dir: PathArgument = DEFAULT_MERGE_SRC, 522 *, 523 branch: BranchOption, 524 release: ReleaseOption = False, 525 props_file: PropsOption, 526): 527 logger.warning("This command is deprecated, use `hexdoc build` instead.") 528 build( 529 output_dir, 530 branch=branch, 531 release=release, 532 props_file=props_file, 533 ) 534 535 536@app.command(deprecated=True) 537def render( 538 output_dir: PathArgument = DEFAULT_MERGE_SRC, 539 *, 540 branch: BranchOption, 541 release: ReleaseOption = False, 542 clean: bool = False, 543 lang: Optional[str] = None, 544 props_file: PropsOption, 545): 546 logger.warning("This command is deprecated, use `hexdoc build` instead.") 547 if lang is not None: 548 logger.warning( 549 "`--lang` is deprecated and has been removed from `hexdoc build`." 550 ) 551 build( 552 output_dir, 553 branch=branch, 554 release=release, 555 clean=clean, 556 props_file=props_file, 557 ) 558 559 560if __name__ == "__main__": 561 app()
logger =
<Logger hexdoc.cli.app (WARNING)>
def
set_default_env():
62def set_default_env(): 63 """Sets placeholder values for unneeded environment variables.""" 64 for key, value in { 65 "GITHUB_REPOSITORY": "placeholder/placeholder", 66 "GITHUB_SHA": "", 67 "GITHUB_PAGES_URL": "", 68 }.items(): 69 os.environ.setdefault(key, value)
Sets placeholder values for unneeded environment variables.
@dataclass(kw_only=True)
class
LoadedBookInfo:
72@dataclass(kw_only=True) 73class LoadedBookInfo: 74 language: str 75 i18n: I18n 76 context: dict[str, Any] 77 book_id: ResourceLocation 78 book: Any
LoadedBookInfo( *, language: str, i18n: hexdoc.minecraft.I18n, context: dict[str, typing.Any], book_id: hexdoc.core.ResourceLocation, book: Any)
i18n: hexdoc.minecraft.I18n
book_id: hexdoc.core.ResourceLocation
app =
<typer.main.Typer object>
def
version_callback(value: bool):
@app.callback()
def
callback( verbosity: typing.Annotated[int, <typer.models.OptionInfo object>] = 0, quiet_lang: Optional[list[str]] = None, version: typing.Annotated[bool, <typer.models.OptionInfo object>] = False):
91@app.callback() 92def callback( 93 verbosity: VerbosityOption = 0, 94 quiet_lang: Optional[list[str]] = None, 95 version: Annotated[ 96 bool, 97 Option("--version", "-V", callback=version_callback, is_eager=True), 98 ] = False, 99): 100 if quiet_lang: 101 logger.warning( 102 "`--quiet-lang` is deprecated, use `props.lang.{lang}.quiet` instead." 103 ) 104 setup_logging(verbosity, ci=False, quiet_langs=quiet_lang)
@app.command()
def
repl( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>]):
107@app.command() 108def repl(*, props_file: PropsOption): 109 """Start a Python shell with some helpful extra locals added from hexdoc.""" 110 111 repl_locals = dict[str, Any]( 112 props_path=props_file, 113 ) 114 115 try: 116 props, pm, book_plugin, plugin = load_common_data(props_file, branch="") 117 repl_locals |= dict( 118 props=props, 119 pm=pm, 120 plugin=plugin, 121 ) 122 123 loader = ModResourceLoader.load_all( 124 props, 125 pm, 126 export=False, 127 ) 128 repl_locals["loader"] = loader 129 130 if props.book_id: 131 book_id, book_data = book_plugin.load_book_data(props.book_id, loader) 132 else: 133 book_id = None 134 book_data = {} 135 136 i18n = I18n.load_all( 137 loader, 138 enabled=book_plugin.is_i18n_enabled(book_data), 139 )[props.default_lang] 140 141 all_metadata = loader.load_metadata(model_type=HexdocMetadata) 142 repl_locals["all_metadata"] = all_metadata 143 144 if book_id and book_data: 145 context = init_context( 146 book_id=book_id, 147 book_data=book_data, 148 pm=pm, 149 loader=loader, 150 i18n=i18n, 151 all_metadata=all_metadata, 152 ) 153 book = book_plugin.validate_book(book_data, context=context) 154 repl_locals |= dict( 155 book=book, 156 context=context, 157 ) 158 except Exception as e: 159 print(e) 160 161 code.interact( 162 banner=dedent( 163 f"""\ 164 [hexdoc repl] Python {sys.version} 165 Locals: {', '.join(sorted(repl_locals.keys()))}""" 166 ), 167 readfunc=repl_readfunc(), 168 local=repl_locals, 169 exitmsg="", 170 )
Start a Python shell with some helpful extra locals added from hexdoc.
@app.command()
def
build( output_dir: typing.Annotated[pathlib.Path, <typer.models.ArgumentInfo object>] = PosixPath('_site/src/docs'), *, branch: typing.Annotated[str, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>] = False, clean: bool = False, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>]) -> pathlib.Path:
173@app.command() 174def build( 175 output_dir: PathArgument = DEFAULT_MERGE_SRC, 176 *, 177 branch: BranchOption, 178 release: ReleaseOption = False, 179 clean: bool = False, 180 props_file: PropsOption, 181) -> Path: 182 """Export resources and render the web book. 183 184 For developers: returns the site path (eg. `/v/latest/main`). 185 """ 186 187 props, pm, book_plugin, plugin = load_common_data(props_file, branch) 188 189 if props.env.hexdoc_subdirectory: 190 output_dir /= props.env.hexdoc_subdirectory 191 192 logger.info("Exporting resources.") 193 with ModResourceLoader.clean_and_load_all(props, pm, export=True) as loader: 194 site_path = plugin.site_path(versioned=release) 195 site_dir = output_dir / site_path 196 197 asset_loader = plugin.asset_loader( 198 loader=loader, 199 site_url=props.env.github_pages_url.joinpath(*site_path.parts), 200 asset_url=props.env.asset_url, 201 render_dir=site_dir, 202 ) 203 204 all_metadata = render_textures_and_export_metadata(loader, asset_loader) 205 206 if not props.book_id: 207 logger.info("Skipping book load because props.book_id is not set.") 208 return site_dir 209 210 book_id, book_data = book_plugin.load_book_data(props.book_id, loader) 211 212 all_i18n = I18n.load_all( 213 loader, 214 enabled=book_plugin.is_i18n_enabled(book_data), 215 ) 216 217 logger.info("Loading books for all languages.") 218 books = list[LoadedBookInfo]() 219 for language, i18n in all_i18n.items(): 220 try: 221 context = init_context( 222 book_id=book_id, 223 book_data=book_data, 224 pm=pm, 225 loader=loader, 226 i18n=i18n, 227 all_metadata=all_metadata, 228 ) 229 book = book_plugin.validate_book(book_data, context=context) 230 books.append( 231 LoadedBookInfo( 232 language=language, 233 i18n=i18n, 234 context=context, 235 book_id=book_id, 236 book=book, 237 ) 238 ) 239 except Exception: 240 if not props.lang[language].ignore_errors: 241 raise 242 logger.exception(f"Failed to load book for {language}") 243 244 if not props.template: 245 logger.info("Skipping book render because props.template is not set.") 246 return site_dir 247 248 if not isinstance(plugin, ModPluginWithBook): 249 raise ValueError( 250 f"ModPlugin registered for modid `{props.modid}` (from props.modid)" 251 f" does not inherit from ModPluginWithBook: {plugin}" 252 ) 253 254 logger.info("Setting up Jinja template environment.") 255 env = create_jinja_env(pm, props.template.include, props_file) 256 257 logger.info(f"Rendering book for {len(books)} language(s).") 258 for book_info in books: 259 try: 260 templates = get_templates( 261 props=props, 262 pm=pm, 263 book=book_info.book, 264 context=book_info.context, 265 env=env, 266 ) 267 if not templates: 268 raise RuntimeError( 269 "No templates to render, check your props.template configuration " 270 f"(in {props_file.as_posix()})" 271 ) 272 273 book_ctx = BookContext.of(book_info.context) 274 formatting_ctx = FormattingContext.of(book_info.context) 275 texture_ctx = TextureContext.of(book_info.context) 276 277 site_book_path = plugin.site_book_path( 278 book_info.language, 279 versioned=release, 280 ) 281 if clean: 282 shutil.rmtree(output_dir / site_book_path, ignore_errors=True) 283 284 template_args: dict[str, Any] = book_info.context | { 285 "all_metadata": all_metadata, 286 "png_textures": PNGTexture.get_lookup(texture_ctx.textures), 287 "animations": sorted( # this MUST be sorted to avoid flaky tests 288 AnimatedTexture.get_lookup(texture_ctx.textures).values(), 289 key=lambda t: t.css_class, 290 ), 291 "book": book_info.book, 292 "book_links": book_ctx.book_links, 293 } 294 295 render_book( 296 props=props, 297 pm=pm, 298 plugin=plugin, 299 lang=book_info.language, 300 book_id=book_info.book_id, 301 i18n=book_info.i18n, 302 macros=formatting_ctx.macros, 303 env=env, 304 templates=templates, 305 output_dir=output_dir, 306 version=plugin.mod_version if release else f"latest/{branch}", 307 site_path=site_book_path, 308 versioned=release, 309 template_args=template_args, 310 ) 311 except Exception: 312 if not props.lang[book_info.language].ignore_errors: 313 raise 314 logger.exception(f"Failed to render book for {book_info.language}") 315 316 logger.info("Done.") 317 return site_dir
Export resources and render the web book.
For developers: returns the site path (eg. /v/latest/main
).
@app.command()
def
merge( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], src: pathlib.Path = PosixPath('_site/src/docs'), dst: pathlib.Path = PosixPath('_site/dst/docs'), release: typing.Annotated[bool, <typer.models.OptionInfo object>] = False):
320@app.command() 321def merge( 322 *, 323 props_file: PropsOption, 324 src: Path = DEFAULT_MERGE_SRC, 325 dst: Path = DEFAULT_MERGE_DST, 326 release: ReleaseOption = False, 327): 328 props, _, _, plugin = load_common_data(props_file, branch="", book=True) 329 if not props.template: 330 raise ValueError("Expected a value for props.template, got None") 331 332 if props.env.hexdoc_subdirectory: 333 src /= props.env.hexdoc_subdirectory 334 dst /= props.env.hexdoc_subdirectory 335 336 dst.mkdir(parents=True, exist_ok=True) 337 338 # remove any stale data that we're about to replace 339 delete_updated_books(src=src, dst=dst, release=release) 340 341 # do the merge 342 shutil.copytree(src=src, dst=dst, dirs_exist_ok=True) 343 344 # rebuild the sitemap 345 sitemap, minecraft_sitemap = load_sitemap(dst) 346 dump_sitemap(dst, sitemap, minecraft_sitemap) 347 348 # find paths for redirect pages 349 redirects = dict[Path, str]() 350 351 root_version: Version | None = None 352 root_redirect: str | None = None 353 354 for version, item in sitemap.items(): 355 if version.startswith("latest"): # TODO: check type of item instead 356 continue 357 358 redirects[plugin.site_root / version] = item.default_marker.redirect_contents 359 for lang, marker in item.markers.items(): 360 redirects[plugin.site_root / version / lang] = marker.redirect_contents 361 362 item_version = Version(version) 363 if not root_version or item_version > root_version: 364 root_version = item_version 365 root_redirect = item.default_marker.redirect_contents 366 367 if root_redirect is None: 368 # TODO: use plugin to build this path 369 # TODO: refactor 370 if item := sitemap.get(f"latest/{props.default_branch}"): 371 root_redirect = item.default_marker.redirect_contents 372 elif sitemap: 373 key = sorted(sitemap.keys())[0] 374 root_redirect = sitemap[key].default_marker.redirect_contents 375 logger.warning( 376 f"No book exists for the default branch `{props.default_branch}`, generating root redirect to `{key}` (check the value of `default_branch` in hexdoc.toml)" 377 ) 378 else: 379 logger.error("No books found, skipping root redirect") 380 381 if root_redirect is not None: 382 redirects[Path()] = root_redirect 383 384 # write redirect pages 385 if props.template.redirect: 386 filename, _ = props.template.redirect 387 for path, redirect_contents in redirects.items(): 388 write_to_path(dst / path / filename, redirect_contents) 389 390 # bypass Jekyll on GitHub Pages 391 (dst / ".nojekyll").touch()
@app.command()
def
serve( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], port: int = 8000, src: pathlib.Path = PosixPath('_site/src/docs'), dst: pathlib.Path = PosixPath('_site/dst/docs'), branch: typing.Annotated[str, <typer.models.OptionInfo object>], release: bool = True, clean: bool = False, do_merge: typing.Annotated[bool, <typer.models.OptionInfo object>] = True):
394@app.command() 395def serve( 396 *, 397 props_file: PropsOption, 398 port: int = 8000, 399 src: Path = DEFAULT_MERGE_SRC, 400 dst: Path = DEFAULT_MERGE_DST, 401 branch: BranchOption, 402 release: bool = True, 403 clean: bool = False, 404 do_merge: Annotated[bool, Option("--merge/--no-merge")] = True, 405): 406 book_root = dst 407 relative_root = book_root.resolve().relative_to(Path.cwd()) 408 409 base_url = URL.build(scheme="http", host="localhost", port=port) 410 book_url = base_url.joinpath(*relative_root.parts) 411 412 repo_root = git_root(props_file.parent) 413 asset_root = repo_root.relative_to(Path.cwd()) 414 415 os.environ |= { 416 # prepend a slash to the path so it can find the texture in the local repo 417 # eg. http://localhost:8000/_site/src/docs/Common/... 418 # vs. http://localhost:8000/Common/... 419 "DEBUG_GITHUBUSERCONTENT": str(base_url.joinpath(*asset_root.parts)), 420 "GITHUB_PAGES_URL": str(book_url), 421 } 422 423 print() 424 logger.info(f"hexdoc build --{'' if release else 'no-'}release") 425 build( 426 branch=branch, 427 props_file=props_file, 428 output_dir=src, 429 release=True, 430 clean=clean, 431 ) 432 433 if do_merge: 434 print() 435 logger.info("hexdoc merge") 436 merge( 437 src=src, 438 dst=dst, 439 props_file=props_file, 440 ) 441 442 print() 443 logger.info(f"Serving web book at {book_url} (press ctrl+c to exit)\n") 444 with HTTPServer(("", port), SimpleHTTPRequestHandler) as httpd: 445 # ignore KeyboardInterrupt to stop Typer from printing "Aborted." 446 # because it keeps printing after nodemon exits and breaking the output 447 try: 448 httpd.serve_forever() 449 except KeyboardInterrupt: 450 pass
@app.command()
def
render_model( model_id: str, *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], output_path: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>] = PosixPath('out.png'), axes: bool = False, normals: bool = False):
453@app.command() 454def render_model( 455 model_id: str, 456 *, 457 props_file: PropsOption, 458 output_path: Annotated[Path, Option("--output", "-o")] = Path("out.png"), 459 axes: bool = False, 460 normals: bool = False, 461): 462 """Use hexdoc's block rendering to render an item or block model.""" 463 set_default_env() 464 props, pm, *_ = load_common_data(props_file, branch="") 465 466 debug = DebugType.NONE 467 if axes: 468 debug |= DebugType.AXES 469 if normals: 470 debug |= DebugType.NORMALS 471 472 with ModResourceLoader.load_all(props, pm, export=False) as loader: 473 _, model = load_model(loader, ResourceLocation.from_str(model_id)) 474 while isinstance(model, ItemModel) and model.parent: 475 _, model = load_model(loader, model.parent) 476 477 if isinstance(model, ItemModel): 478 raise ValueError(f"Invalid block id: {model_id}") 479 480 with BlockRenderer(loader=loader, debug=debug) as renderer: 481 renderer.render_block_model(model, output_path)
Use hexdoc's block rendering to render an item or block model.
@app.command()
def
render_models( model_ids: Annotated[Optional[list[str]], <typer.models.ArgumentInfo object>] = None, *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], render_all: typing.Annotated[bool, <typer.models.OptionInfo object>] = False, output_dir: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>] = PosixPath('out'), site_url_str: Annotated[Optional[str], <typer.models.OptionInfo object>] = None, export_resources: bool = True):
484@app.command() 485def render_models( 486 model_ids: Annotated[Optional[list[str]], Argument()] = None, 487 *, 488 props_file: PropsOption, 489 render_all: Annotated[bool, Option("--all")] = False, 490 output_dir: Annotated[Path, Option("--output", "-o")] = Path("out"), 491 site_url_str: Annotated[Optional[str], Option("--site-url")] = None, 492 export_resources: bool = True, 493): 494 if not (model_ids or render_all): 495 raise ValueError("At least one model id must be provided if --all is missing") 496 497 site_url = URL(site_url_str or "") 498 499 set_default_env() 500 props, pm, _, plugin = load_common_data(props_file, branch="") 501 502 with ModResourceLoader.load_all(props, pm, export=export_resources) as loader: 503 if model_ids: 504 with BlockRenderer(loader=loader, output_dir=output_dir) as renderer: 505 for model_id in model_ids: 506 model_id = ResourceLocation.from_str(model_id) 507 render_block(model_id, renderer, site_url) 508 else: 509 asset_loader = plugin.asset_loader( 510 loader=loader, 511 site_url=site_url, 512 asset_url=props.env.asset_url, 513 render_dir=output_dir, 514 ) 515 render_textures_and_export_metadata(loader, asset_loader) 516 517 logger.info("Done.")
@app.command(deprecated=True)
def
export( output_dir: typing.Annotated[pathlib.Path, <typer.models.ArgumentInfo object>] = PosixPath('_site/src/docs'), *, branch: typing.Annotated[str, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>] = False, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>]):
520@app.command(deprecated=True) 521def export( 522 output_dir: PathArgument = DEFAULT_MERGE_SRC, 523 *, 524 branch: BranchOption, 525 release: ReleaseOption = False, 526 props_file: PropsOption, 527): 528 logger.warning("This command is deprecated, use `hexdoc build` instead.") 529 build( 530 output_dir, 531 branch=branch, 532 release=release, 533 props_file=props_file, 534 )
@app.command(deprecated=True)
def
render( output_dir: typing.Annotated[pathlib.Path, <typer.models.ArgumentInfo object>] = PosixPath('_site/src/docs'), *, branch: typing.Annotated[str, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>] = False, clean: bool = False, lang: Optional[str] = None, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>]):
537@app.command(deprecated=True) 538def render( 539 output_dir: PathArgument = DEFAULT_MERGE_SRC, 540 *, 541 branch: BranchOption, 542 release: ReleaseOption = False, 543 clean: bool = False, 544 lang: Optional[str] = None, 545 props_file: PropsOption, 546): 547 logger.warning("This command is deprecated, use `hexdoc build` instead.") 548 if lang is not None: 549 logger.warning( 550 "`--lang` is deprecated and has been removed from `hexdoc build`." 551 ) 552 build( 553 output_dir, 554 branch=branch, 555 release=release, 556 clean=clean, 557 props_file=props_file, 558 )