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