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