Edit on GitHub

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)
language: str
context: dict[str, typing.Any]
book: Any
app = <typer.main.Typer object>
def version_callback(value: bool):
85def version_callback(value: bool):
86    if value:
87        print(f"hexdoc {VERSION}")
88        raise typer.Exit()
@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    )