Skip to content

CLI

Public CLI exports for TexSmith.

debug_enabled

debug_enabled() -> bool

Return whether full tracebacks should be displayed.

Source code in src/texsmith/ui/cli/state.py
273
274
275
276
277
278
def debug_enabled() -> bool:
    """Return whether full tracebacks should be displayed."""
    try:
        return get_cli_state(create=False).show_tracebacks
    except RuntimeError:
        return False

emit_error

emit_error(
    message: str, *, exception: BaseException | None = None
) -> None

Log an error-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
266
267
268
269
270
def emit_error(message: str, *, exception: BaseException | None = None) -> None:
    """Log an error-level message to stderr respecting verbosity settings."""
    if exception is not None and getattr(exception, "_texsmith_logged", False):
        return
    render_message("error", message, exception=exception)

emit_warning

emit_warning(
    message: str, *, exception: BaseException | None = None
) -> None

Log a warning-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
261
262
263
def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
    """Log a warning-level message to stderr respecting verbosity settings."""
    render_message("warning", message, exception=exception)

ensure_rich_compat

ensure_rich_compat() -> None

Patch Rich stub modules provided by tests to expose required attributes.

Source code in src/texsmith/ui/cli/state.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def ensure_rich_compat() -> None:
    """Patch Rich stub modules provided by tests to expose required attributes."""
    import importlib.machinery
    import sys as _sys
    import types

    rich_mod = _sys.modules.get("rich")
    if rich_mod is None:
        return
    if getattr(rich_mod, "__spec__", None) is None:
        rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)

    is_stub = getattr(rich_mod, "__file__", None) is None

    if is_stub:
        try:
            import typer.core as typer_core

            cast(Any, typer_core).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass
        try:
            import typer.main as typer_main

            cast(Any, typer_main).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass

    if not hasattr(rich_mod, "box"):
        box_module = types.ModuleType("rich.box")
        cast(Any, box_module).SQUARE = object()
        cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
        cast(Any, box_module).SIMPLE = object()
        cast(Any, rich_mod).box = box_module
        _sys.modules.setdefault("rich.box", box_module)

get_cli_state

get_cli_state(
    ctx: Context | Context | None = None,
    *,
    create: bool = True,
) -> CLIState

Return the CLI state associated with the active Typer context.

Source code in src/texsmith/ui/cli/state.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_cli_state(
    ctx: typer.Context | click.Context | None = None,
    *,
    create: bool = True,
) -> CLIState:
    """Return the CLI state associated with the active Typer context."""
    if ctx is None:
        try:
            candidate = click.get_current_context(silent=True)
        except RuntimeError:
            candidate = None
        if isinstance(candidate, typer.Context):
            ctx = candidate

    state: CLIState | None = None

    if isinstance(ctx, typer.Context):
        current_ctx: typer.Context | None = ctx
        while current_ctx is not None:
            obj = getattr(current_ctx, "obj", None)
            if isinstance(obj, CLIState):
                state = obj
                break
            current_ctx = getattr(current_ctx, "parent", None)
        if state is None and create:
            state = CLIState()
            current_ctx = ctx
            current_ctx.obj = state
        if state is not None:
            _STATE_VAR.set(state)

    if state is None:
        fallback = _STATE_VAR.get(None)
        if fallback is None:
            if not create:
                raise RuntimeError("CLI state is not initialised for this context.")
            fallback = CLIState()
            _STATE_VAR.set(fallback)
        state = fallback

    return state

main

main() -> None

Entry point compatible with console scripts.

Source code in src/texsmith/ui/cli/app.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def main() -> None:
    """Entry point compatible with console scripts."""
    try:
        app()
    except typer.Exit:
        raise
    except KeyboardInterrupt as exc:
        if debug_enabled():
            raise
        emit_error("Operation cancelled by user.", exception=exc)
        raise typer.Exit(code=1) from exc
    except SystemExit:
        raise
    except Exception as exc:  # pragma: no cover - defensive catch-all
        from .state import get_cli_state

        state = get_cli_state()
        if state.show_tracebacks:
            from rich.traceback import Traceback

            tb = Traceback.from_exception(
                type(exc),
                exc,
                exc.__traceback__,
                show_locals=state.verbosity >= 2,
            )
            state.err_console.print(tb)
        else:
            emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

render

render(
    show_version: Annotated[
        bool,
        Option(
            "--version",
            help="Show the TeXSmith version and exit.",
            is_eager=True,
        ),
    ] = False,
    list_templates_flag: Annotated[
        bool,
        Option(
            "--list-templates",
            help="List available templates (builtin, entry-point, and local) and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    list_bibliography: Annotated[
        bool,
        Option(
            "--list-bibliography",
            help="Print bibliography details from provided .bib files and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    verbose: Annotated[
        int,
        Option(
            "--verbose",
            "-v",
            count=True,
            help="Increase CLI verbosity. Combine multiple times for additional diagnostics.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = 0,
    debug: Annotated[
        bool,
        Option(
            "--debug",
            help="Show full tracebacks when an unexpected error occurs.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    quiet: QuietOption = False,
    strict: StrictOption = False,
    deprecated: DeprecatedOption = None,
    diagnostics_json: DiagnosticsJsonOption = None,
    inputs: InputPathArgument = None,
    input_path: Annotated[
        Path | None,
        Option(
            "--input-path",
            help="Internal helper used for programmatic invocation.",
            hidden=True,
        ),
    ] = None,
    output: OutputPathOption = None,
    base_level: BaseLevelOption = str(
        _REQUEST_DEFAULTS.base_level
    ),
    strip_heading: StripHeadingOption = _REQUEST_DEFAULTS.strip_heading_all,
    no_promote_title: NoPromoteTitleOption = not _REQUEST_DEFAULTS.promote_title,
    no_title: NoTitleOption = _REQUEST_DEFAULTS.suppress_title,
    include_paths: IncludePathOption = None,
    no_copy_assets: NoCopyAssetsOption = not _REQUEST_DEFAULTS.copy_assets,
    convert_assets: ConvertAssetsOption = _REQUEST_DEFAULTS.convert_assets,
    hash_assets: HashAssetsOption = _REQUEST_DEFAULTS.hash_assets,
    http_user_agent: HttpUserAgentOption = _REQUEST_DEFAULTS.http_user_agent,
    diagrams_backend: Annotated[
        str | None,
        Option(
            "--diagrams-backend",
            metavar="BACKEND",
            help="Force the backend for diagram conversion (draw.io, mermaid): playwright, local, or docker (auto-default).",
            case_sensitive=False,
        ),
    ] = _REQUEST_DEFAULTS.diagrams_backend,
    manifest: ManifestOptionWithShort = _REQUEST_DEFAULTS.manifest,
    make_deps: MakefileDepsOption = False,
    template: TemplateOption = None,
    embed_documents: Annotated[
        bool,
        Option(
            "--embed",
            help="Embed converted documents into the main document instead linking them with \\input.",
        ),
    ] = _REQUEST_DEFAULTS.embed_documents,
    enable_fragments: EnableFragmentOption = None,
    disable_fragments: DisableFragmentOption = None,
    template_attributes: TemplateAttributeOption = None,
    debug_ir: DebugIrOption = None,
    classic_output: Annotated[
        bool,
        Option(
            "--classic-output",
            help="Display raw latexmk output without parsing.",
        ),
    ] = False,
    output_format: FormatOption = "latex",
    build_pdf: Annotated[
        bool,
        Option(
            "-b",
            "--build",
            help="Invoke latexmk after rendering to compile the resulting LaTeX project.",
        ),
    ] = False,
    engine: Annotated[
        str,
        Option(
            "-e",
            "--engine",
            help="LaTeX engine backend to use when building (tectonic, lualatex, xelatex).",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = "tectonic",
    system_tectonic: Annotated[
        bool,
        Option(
            "-S",
            "--system",
            help="Use the system Tectonic binary instead of the bundled download.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    language: LanguageOption = _REQUEST_DEFAULTS.language,
    numbering: NumberingOption = "backend",
    legacy_latex_accents: Annotated[
        bool,
        Option(
            "--legacy-latex-accents",
            help="Escape accented characters and ligatures with legacy LaTeX macros instead of emitting Unicode glyphs (defaults to Unicode output).",
        ),
    ] = _REQUEST_DEFAULTS.legacy_latex_accents,
    slots: SlotsOption = None,
    open_log: OpenLogOption = False,
    snippet_dump_dir: Annotated[
        Path | None,
        Option(
            "--dump-snippets",
            metavar="DIR",
            help="Copy snippet render sources (tex, aux files) into DIR for inspection.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = None,
    isolate_cache: Annotated[
        bool,
        Option(
            "--isolate",
            help="Use a per-render TeX cache inside the output directory instead of the shared ~/.cache/texsmith cache.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    template_info_flag: TemplateInfoOption = False,
    template_scaffold: Annotated[
        Path | None,
        Option(
            "--template-scaffold",
            metavar="DEST",
            help="Copy the selected template into DEST and exit.",
        ),
    ] = None,
    fonts_info: FontsInfoOption = False,
    print_context: Annotated[
        bool,
        Option(
            "--print-context",
            help="Print resolved template context emitters/consumers and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
) -> None

Convert Markdown documents into LaTeX or Typst sources and optionally build PDFs.

Source code in src/texsmith/ui/cli/commands/render.py
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
def render(
    show_version: Annotated[
        bool,
        typer.Option(
            "--version",
            help="Show the TeXSmith version and exit.",
            is_eager=True,
        ),
    ] = False,
    list_templates_flag: Annotated[
        bool,
        typer.Option(
            "--list-templates",
            help="List available templates (builtin, entry-point, and local) and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    list_bibliography: Annotated[
        bool,
        typer.Option(
            "--list-bibliography",
            help="Print bibliography details from provided .bib files and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    verbose: Annotated[
        int,
        typer.Option(
            "--verbose",
            "-v",
            count=True,
            help=("Increase CLI verbosity. Combine multiple times for additional diagnostics."),
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = 0,
    debug: Annotated[
        bool,
        typer.Option(
            "--debug",
            help="Show full tracebacks when an unexpected error occurs.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    quiet: QuietOption = False,
    strict: StrictOption = False,
    deprecated: DeprecatedOption = None,
    diagnostics_json: DiagnosticsJsonOption = None,
    inputs: InputPathArgument = None,
    input_path: Annotated[
        Path | None,
        typer.Option(
            "--input-path",
            help="Internal helper used for programmatic invocation.",
            hidden=True,
        ),
    ] = None,
    output: OutputPathOption = None,
    base_level: BaseLevelOption = str(_REQUEST_DEFAULTS.base_level),
    strip_heading: StripHeadingOption = _REQUEST_DEFAULTS.strip_heading_all,
    no_promote_title: NoPromoteTitleOption = not _REQUEST_DEFAULTS.promote_title,
    no_title: NoTitleOption = _REQUEST_DEFAULTS.suppress_title,
    include_paths: IncludePathOption = None,
    no_copy_assets: NoCopyAssetsOption = not _REQUEST_DEFAULTS.copy_assets,
    convert_assets: ConvertAssetsOption = _REQUEST_DEFAULTS.convert_assets,
    hash_assets: HashAssetsOption = _REQUEST_DEFAULTS.hash_assets,
    http_user_agent: HttpUserAgentOption = _REQUEST_DEFAULTS.http_user_agent,
    diagrams_backend: Annotated[
        str | None,
        typer.Option(
            "--diagrams-backend",
            metavar="BACKEND",
            help="Force the backend for diagram conversion (draw.io, mermaid): playwright, local, or docker (auto-default).",
            case_sensitive=False,
        ),
    ] = _REQUEST_DEFAULTS.diagrams_backend,
    manifest: ManifestOptionWithShort = _REQUEST_DEFAULTS.manifest,
    make_deps: MakefileDepsOption = False,
    template: TemplateOption = None,
    embed_documents: Annotated[
        bool,
        typer.Option(
            "--embed",
            help="Embed converted documents into the main document instead linking them with \\input.",
        ),
    ] = _REQUEST_DEFAULTS.embed_documents,
    enable_fragments: EnableFragmentOption = None,
    disable_fragments: DisableFragmentOption = None,
    template_attributes: TemplateAttributeOption = None,
    debug_ir: DebugIrOption = None,
    classic_output: Annotated[
        bool,
        typer.Option(
            "--classic-output",
            help=("Display raw latexmk output without parsing."),
        ),
    ] = False,
    output_format: FormatOption = "latex",
    build_pdf: Annotated[
        bool,
        typer.Option(
            "-b",
            "--build",
            help="Invoke latexmk after rendering to compile the resulting LaTeX project.",
        ),
    ] = False,
    engine: Annotated[
        str,
        typer.Option(
            "-e",
            "--engine",
            help="LaTeX engine backend to use when building (tectonic, lualatex, xelatex).",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = "tectonic",
    system_tectonic: Annotated[
        bool,
        typer.Option(
            "-S",
            "--system",
            help="Use the system Tectonic binary instead of the bundled download.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    language: LanguageOption = _REQUEST_DEFAULTS.language,
    numbering: NumberingOption = "backend",
    legacy_latex_accents: Annotated[
        bool,
        typer.Option(
            "--legacy-latex-accents",
            help=(
                "Escape accented characters and ligatures with legacy LaTeX macros instead of "
                "emitting Unicode glyphs (defaults to Unicode output)."
            ),
        ),
    ] = _REQUEST_DEFAULTS.legacy_latex_accents,
    slots: SlotsOption = None,
    open_log: OpenLogOption = False,
    snippet_dump_dir: Annotated[
        Path | None,
        typer.Option(
            "--dump-snippets",
            metavar="DIR",
            help="Copy snippet render sources (tex, aux files) into DIR for inspection.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = None,
    isolate_cache: Annotated[
        bool,
        typer.Option(
            "--isolate",
            help=(
                "Use a per-render TeX cache inside the output directory instead of the shared "
                "~/.cache/texsmith cache."
            ),
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    template_info_flag: TemplateInfoOption = False,
    template_scaffold: Annotated[
        Path | None,
        typer.Option(
            "--template-scaffold",
            metavar="DEST",
            help="Copy the selected template into DEST and exit.",
        ),
    ] = None,
    fonts_info: FontsInfoOption = False,
    print_context: Annotated[
        bool,
        typer.Option(
            "--print-context",
            help="Print resolved template context emitters/consumers and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
) -> None:
    """Convert Markdown documents into LaTeX or Typst sources and optionally build PDFs."""

    ctx = click.get_current_context(silent=True)
    typer_ctx = ctx if isinstance(ctx, typer.Context) else None

    if typer_ctx is not None and typer_ctx.resilient_parsing:
        return

    if show_version:
        typer.echo(get_version())
        raise typer.Exit()

    state = set_cli_state(ctx=typer_ctx, verbosity=verbose, debug=debug, quiet=quiet)
    output_format = (output_format or "latex").strip().lower()
    if output_format not in {"latex", "typst"}:
        raise typer.BadParameter("--format must be 'latex' or 'typst'.")
    # The Typst backend emits a ``.typ`` via the shared IR. With a template it
    # wraps the body in the template's [typst.template] scaffolding; without one
    # it emits a standalone document. It does not use the LaTeX fragment/engine
    # machinery, so the template *info/scaffold* flags are unsupported here.
    if output_format == "typst" and (template_info_flag or template_scaffold is not None):
        raise typer.BadParameter(
            "--format typst does not support --template-info/--template-scaffold."
        )
    numbering = (numbering or "backend").strip().lower()
    if numbering not in NUMBERING_MODES:
        raise typer.BadParameter("--numbering must be 'backend' or 'tmark'.")
    if deprecated is not None:
        deprecated = deprecated.strip().lower()
        if deprecated not in DEPRECATED_LEVELS:
            raise typer.BadParameter("--deprecated must be 'warning', 'info' or 'off'.")
    if snippet_dump_dir is not None:
        os.environ["TEXSMITH_SNIPPET_DUMP_DIR"] = str(snippet_dump_dir)

    if list_templates_flag:
        list_templates()
        raise typer.Exit()

    document_paths = list(inputs or [])
    if input_path is not None:
        if document_paths:
            raise typer.BadParameter("Provide either positional inputs or --input-path, not both.")
        document_paths = [input_path]

    if not document_paths:
        stdin_document = _read_stdin_document()
        if stdin_document is not None:
            document_paths = [stdin_document]

    try:
        split_result = _SERVICE.split_inputs(document_paths)
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

    document_paths = split_result.documents
    bibliography_files = split_result.bibliography_files
    shared_front_matter = split_result.front_matter
    shared_front_matter_paths = split_result.front_matter_paths

    template_requested = template_info_flag or template_scaffold

    if list_bibliography:
        collection = BibliographyCollection()
        if bibliography_files:
            collection.load_files(bibliography_files)
        print_bibliography_overview(collection)
        raise typer.Exit()

    if not document_paths:
        if template_requested:
            identifier = template or "article"
            if template_info_flag:
                show_template_info(identifier)
            if template_scaffold:
                scaffold_template(identifier, template_scaffold)
            raise typer.Exit()
        raise typer.BadParameter(
            "Provide a Markdown (.md) source document or pipe content via stdin."
        )

    copy_assets = not no_copy_assets
    primary_front_matter: Mapping[str, Any] | None = shared_front_matter
    first_document = document_paths[0] if document_paths else None
    if primary_front_matter is None and first_document is not None:
        front_matter = _load_front_matter(first_document)
        if front_matter:
            primary_front_matter = front_matter

    fm_payload: dict[str, Any] = {}
    if isinstance(primary_front_matter, Mapping):
        fm_payload = dict(primary_front_matter)
        try:
            normalise_press_metadata(fm_payload)
        except PressMetadataError as exc:
            raise typer.BadParameter(str(exc)) from exc

    # The template has to be known before the two flags that report on one and
    # exit; everything else the flags imply is settled by ``resolve_render_plan``.
    template_param_source = ctx.get_parameter_source("template") if ctx else None
    no_promote_param_source = ctx.get_parameter_source("no_promote_title") if ctx else None
    if template is None and template_param_source in {None, ParameterSource.DEFAULT}:
        template = declared_template(primary_front_matter)

    if template_requested:
        identifier = template or "article"
        if template_info_flag:
            show_template_info(identifier)
        if template_scaffold:
            scaffold_template(identifier, template_scaffold)
        raise typer.Exit()

    attribute_overrides = _parse_template_attributes(template_attributes)
    has_attributes = bool(attribute_overrides)
    if attribute_overrides:
        try:
            normalise_press_metadata(attribute_overrides)
        except PressMetadataError as exc:
            raise typer.BadParameter(str(exc)) from exc
    if engine:
        attribute_overrides.setdefault("_texsmith_latex_engine", engine)
    if numbering != "backend":
        # Read by both IR backends (``core.conversion.resolution.numbering_mode``).
        attribute_overrides[NUMBERING_OVERRIDE_KEY] = numbering

    try:
        plan = resolve_render_plan(
            front_matter=fm_payload,
            template=template,
            build_pdf=build_pdf,
            output=output,
            output_format=output_format,
            print_context=print_context,
            has_attributes=has_attributes,
            no_title=no_title,
            strip_heading=strip_heading,
            no_promote_title=no_promote_title,
            no_promote_defaulted=no_promote_param_source in {None, ParameterSource.DEFAULT},
            base_level=base_level,
            base_level_defaulted=(ctx.get_parameter_source("base_level") if ctx else None)
            in {None, ParameterSource.DEFAULT},
            classic_output=classic_output,
            open_log=open_log,
            make_deps=make_deps,
            strict=strict,
            deprecated=deprecated,
            template_options=attribute_overrides,
            ci_runner=os.environ.get("ACT") == "true",
        )
    except RenderOptionError as exc:
        raise typer.BadParameter(str(exc)) from exc

    template = plan.template
    template_selected = plan.template_selected
    build_pdf = plan.build_pdf
    classic_output = plan.classic_output
    promote_title = plan.promote_title
    numbered = plan.numbered
    strict = plan.strict
    deprecated = plan.deprecated
    resolved_base_level = plan.base_level
    for notice in plan.notices:
        typer.echo(notice)

    if attribute_overrides:
        state.record_event("template_attributes", {"values": attribute_overrides})

    try:
        _, slot_assignments = organise_slot_overrides(slots, document_paths)
    except (typer.BadParameter, ValueError) as exc:
        raise typer.BadParameter(str(exc)) from exc

    state_slot_rows: list[dict[str, object]] = []
    for doc_path, entries in slot_assignments.items():
        for entry in entries:
            state_slot_rows.append(
                {
                    "document": _format_path_for_event(doc_path),
                    "slot": entry.slot,
                    "selector": entry.selector,
                    "include_document": entry.include_document,
                }
            )
    if state_slot_rows:
        state.record_event("slot_assignments", {"entries": state_slot_rows})

    emitter = _RenderEmitter(state=state, debug_enabled=debug_enabled(), deprecated=deprecated)
    presented_diagnostics = 0

    def _flush_diagnostics() -> None:
        """Close a phase: event lines, the diagnostics summary, the JSON dump, ``--strict``.

        Called at every exit of the command and, when building, once more
        before the engine runs so the ``.tex`` is there to inspect when
        ``--strict`` stops the run.
        """
        nonlocal presented_diagnostics
        lines: list[str] = list(consume_event_diagnostics(state))
        for line in lines:
            typer.echo(line)
        if len(emitter.sink) > presented_diagnostics:
            presented_diagnostics = len(emitter.sink)
            present_diagnostics_summary(state, emitter.sink)
        if diagnostics_json is not None:
            try:
                write_diagnostics_json(diagnostics_json, emitter.sink)
            except OSError as exc:
                emit_error(f"Failed to write diagnostics to '{diagnostics_json}': {exc}", exc)
                raise typer.Exit(code=1) from exc
        if strict and emitter.sink.strict_failed():
            emit_error("Diagnostics were recorded and --strict is on.")
            raise typer.Exit(code=1)

    debug_snapshot = debug_ir if debug_ir is not None else debug_enabled()

    output_mode, output_target = determine_output_target(template_selected, document_paths, output)
    resolved_output_target = output_target.resolve() if output_target is not None else None

    temp_render_dir: Path | None = None
    cleanup_render_dir = False
    cleanup_render_dir_path: Path | None = None
    final_pdf_target: Path | None = None

    if template_selected:
        if output_mode == "template-pdf":
            temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
            typer.echo(f"Using temporary output directory: {temp_render_dir}")
            cleanup_render_dir = True
            cleanup_render_dir_path = temp_render_dir
            final_pdf_target = resolved_output_target
        elif build_pdf and output_mode == "template" and output is None:
            # Keyed on the absence of ``-o`` itself, not on Typer's parameter
            # source: that source is ``None`` whenever the command runs without
            # a click context, which used to route an explicit ``-o dir`` to a
            # temporary directory and drop the PDF in the current directory.
            temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
            typer.echo(f"Using temporary output directory: {temp_render_dir}")
            cleanup_render_dir = True
            cleanup_render_dir_path = temp_render_dir
            primary_name = document_paths[0].stem if document_paths else "texsmith"
            final_pdf_target = Path.cwd() / f"{primary_name}.pdf"

    render_dir_path: Path | None = None
    if template_selected:
        render_dir_path = temp_render_dir or resolved_output_target
    elif output_mode == "directory":
        render_dir_path = resolved_output_target

    if template_selected and render_dir_path is None:
        raise typer.BadParameter("Unable to resolve template output directory.")

    if not embed_documents and template_selected and len(document_paths) == 1:
        embed_documents = True

    request_render_dir = render_dir_path

    request = ConversionRequest(
        copy_assets=copy_assets,
        convert_assets=convert_assets,
        hash_assets=hash_assets,
        manifest=manifest,
        persist_debug_ir=bool(debug_snapshot),
        language=language,
        http_user_agent=http_user_agent,
        legacy_latex_accents=legacy_latex_accents,
        diagrams_backend=diagrams_backend.lower() if isinstance(diagrams_backend, str) else None,
        documents=document_paths,
        bibliography_files=bibliography_files,
        front_matter=shared_front_matter,
        front_matter_paths=shared_front_matter_paths,
        slot_assignments=slot_assignments,
        include_paths=list(include_paths or ()),
        base_level=resolved_base_level,
        strip_heading_all=strip_heading if build_pdf else False,
        strip_heading_first_document=False if build_pdf else strip_heading,
        promote_title=promote_title,
        suppress_title=no_title,
        numbered=numbered,
        template=template,
        render_dir=request_render_dir,
        template_options=attribute_overrides,
        embed_documents=embed_documents,
        enable_fragments=enable_fragments or [],
        disable_fragments=disable_fragments or [],
        emitter=emitter,
    )
    state.record_event(
        "conversion_settings",
        {
            "copy_assets": copy_assets,
            "convert_assets": convert_assets,
            "hash_assets": hash_assets,
            "manifest": manifest,
        },
    )

    try:
        prepared = _SERVICE.prepare_documents(request)
    except UnsupportedInputError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

    if output_format == "typst":
        typst_output_dir: Path | None = None
        if output_mode in {"directory", "template"}:
            typst_output_dir = resolved_output_target
        elif output_mode == "file" and resolved_output_target is not None:
            typst_output_dir = resolved_output_target.parent

        try:
            payload = render_typst_documents(
                prepared.documents, request, output_dir=typst_output_dir, emitter=emitter
            )
        except (ConversionError, TemplateError) as exc:
            # ``raise_conversion_error`` already put the record in the sink
            # and marked the exception as logged, so the message reaches the
            # console only if this exit flushes like every other one.
            _flush_diagnostics()
            emit_error(str(exc), exception=exc)
            raise typer.Exit(code=1) from exc

        if output_mode == "stdout":
            typer.echo(payload)
            _flush_diagnostics()
            return

        if output_mode == "file":
            if resolved_output_target is None:
                raise typer.BadParameter("Output path is required when writing Typst to a file.")
            target = resolved_output_target
        elif output_mode in {"directory", "template"}:
            if resolved_output_target is None:
                raise typer.BadParameter("Output directory is required when writing Typst files.")
            # Several documents make one ``main.typ``, as they make one ``main.tex``.
            stem = (
                prepared.documents[0].source_path.stem if len(prepared.documents) == 1 else "main"
            )
            target = resolved_output_target / f"{stem}.typ"
        else:
            raise RuntimeError(f"Unsupported output mode '{output_mode}' for Typst output.")
        try:
            write_output_file(target, payload)
        except OSError as exc:
            emit_error(str(exc), exception=exc)
            raise typer.Exit(code=1) from exc
        typer.echo(f"Wrote {target}")
        if build_pdf:
            ok, message = build_typst_pdf(target)
            typer.echo(message)
            if not ok and debug_enabled():
                raise typer.Exit(code=1)
        _flush_diagnostics()
        return

    engine_env_key = "TEXSMITH_SELECTED_ENGINE"
    previous_engine_value = os.environ.get(engine_env_key)
    if engine:
        os.environ[engine_env_key] = engine
    else:
        os.environ.pop(engine_env_key, None)
    try:
        response = _SERVICE.execute(request, prepared=prepared)
    except (TemplateError, ConversionError) as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    finally:
        if previous_engine_value is None:
            os.environ.pop(engine_env_key, None)
        else:
            os.environ[engine_env_key] = previous_engine_value

    if not template_selected:
        bundle = response.bundle

        if output_mode == "stdout":
            typer.echo(bundle.combined_output())
            _flush_diagnostics()
            return

        if output_mode == "file":
            if resolved_output_target is None:
                raise typer.BadParameter("Output path is required when writing to a file.")
            try:
                write_output_file(resolved_output_target, bundle.combined_output())
            except OSError as exc:
                emit_error(str(exc), exception=exc)
                raise typer.Exit(code=1) from exc
            present_conversion_summary(
                state=state,
                output_mode=output_mode,
                bundle=bundle,
                output_path=resolved_output_target,
                render_result=None,
            )
            _flush_diagnostics()
            return

        if output_mode == "directory":
            present_conversion_summary(
                state=state,
                output_mode=output_mode,
                bundle=bundle,
                output_path=request.render_dir,
                render_result=None,
            )
            _flush_diagnostics()
            return

        raise RuntimeError(f"Unsupported output mode '{output_mode}'.")

    render_result = response.render_result

    render_dir = render_result.main_tex_path.parent.resolve()

    if not build_pdf:
        present_conversion_summary(
            state=state,
            output_mode="template",
            bundle=None,
            output_path=render_dir,
            render_result=render_result,
        )
        if print_context:
            present_context_attributes(state=state, render_result=render_result)
        if fonts_info:
            present_fonts_info(state, render_result)
        _flush_diagnostics()
        return

    # Engine orchestration (binary selection, command/env build, run) lives in
    # adapters.latex.build.build_pdf; the CLI keeps only presentation, the PDF copy,
    # and dependency-file emission. ``build_pdf`` takes the runner as a parameter,
    # which is the seam a test substitutes.
    # The bodies are written: ``--strict`` decides here, before the engine runs.
    _flush_diagnostics()

    engine_choice = resolve_engine(engine, render_result.template_engine)
    state.console.print(f"[bold cyan]Running {engine_choice.label}…[/]")

    try:
        engine_result: EngineResult = pdf_build.build_pdf(
            render_result,
            engine=engine,
            classic_output=classic_output,
            isolate_cache=isolate_cache,
            console=state.console,
            verbosity=state.verbosity,
            use_system_tectonic=system_tectonic,
            run_engine=run_engine_command,
        )
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    except OSError as exc:
        if debug_enabled():
            raise
        emit_error(f"Failed to execute {engine_choice.label}: {exc}", exception=exc)
        raise typer.Exit(code=1) from exc

    if engine_result.returncode != 0:
        messages = engine_result.messages or parse_latex_log(engine_result.log_path)
        present_latex_failure(
            state=state,
            log_path=engine_result.log_path,
            messages=messages,
            open_log=open_log,
        )
        emit_error(f"{engine_choice.label} exited with status {engine_result.returncode}")
        raise typer.Exit(code=engine_result.returncode)

    pdf_path = engine_result.pdf_path
    final_pdf_path = pdf_path
    dep_file_path: Path | None = None

    if final_pdf_target is not None:
        final_destination = final_pdf_target
        try:
            final_destination.parent.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            emit_error(
                f"Unable to create output directory '{final_destination.parent}': {exc}",
                exc,
            )
            raise typer.Exit(code=1) from exc
        try:
            shutil.copy2(pdf_path, final_destination)
        except OSError as exc:
            emit_error(f"Failed to write PDF to '{final_destination}': {exc}", exc)
            raise typer.Exit(code=1) from exc
        final_pdf_path = final_destination

        # The reference inventory is written next to the intermediate ``.tex``,
        # which is a temporary directory in this mode: without this it would be
        # deleted along with it, and its pages — harvested from the ``.aux`` —
        # would never reach anyone.
        _deliver_reference_inventory(render_result.main_tex_path, final_destination.parent)

    if make_deps:
        dependency_paths: set[Path] = set()
        dependency_paths.update(document_paths)
        dependency_paths.update(bibliography_files)
        dependency_paths.update(shared_front_matter_paths)
        dependency_paths.add(render_result.main_tex_path)
        dependency_paths.update(render_result.fragment_paths)
        if render_result.bibliography_path:
            dependency_paths.add(render_result.bibliography_path)
        latexmkrc_candidate = render_dir / ".latexmkrc"
        if latexmkrc_candidate.exists():
            dependency_paths.add(latexmkrc_candidate)
        dependency_paths.update(getattr(render_result, "asset_paths", []))
        dependency_paths.update(getattr(render_result, "asset_sources", []))
        for key in getattr(render_result, "asset_map", {}) or {}:
            candidate_path = Path(key)
            if candidate_path.exists():
                dependency_paths.add(candidate_path)
        try:
            dep_file_path = _write_makefile_deps(final_pdf_path, dependency_paths)
        except OSError as exc:
            emit_error(f"Failed to write dependency file: {exc}", exc)
            raise typer.Exit(code=1) from exc

    present_build_summary(state=state, render_result=render_result, pdf_path=final_pdf_path)
    if fonts_info:
        present_fonts_info(state, render_result)
    if dep_file_path is not None:
        state.console.print(f"[cyan]Dependencies written to[/] {dep_file_path}")
    _flush_diagnostics()

    if cleanup_render_dir and cleanup_render_dir_path is not None:
        shutil.rmtree(cleanup_render_dir_path, ignore_errors=True)

Typer application wiring for the TeXSmith CLI.

HelpOnEmptyCommand

HelpOnEmptyCommand(*args: object, **kwargs: object)

Bases: TyperCommand

Typer command that disables positional argument enforcement.

Source code in src/texsmith/ui/cli/app.py
38
39
40
41
42
def __init__(self, *args: object, **kwargs: object) -> None:  # type: ignore[override]
    super().__init__(*args, **kwargs)
    for param in self.params:
        if isinstance(param, click.Argument):
            param.required = False

RootContext

Bases: Context

The context of a command invoked under its parent's name.

:class:RootGroup leaves the root command unnamed, so Click builds its command path as the group's name followed by nothing; the usage line and every Try '… --help' read better without the gap that leaves.

command_path property

command_path: str

The parent's path, with no room left for the name this command has not got.

RootGroup

Bases: TyperGroup

texsmith is the conversion command, and also the door to the groups.

texsmith doc.md --build is the whole CLI as far as most uses go, so the root keeps taking a document and its options directly: an argument list that does not open with the name of a registered group is the :data:ROOT_COMMAND's. That command then answers under the root's own name — texsmith --help describes the conversion, and names the groups in its epilog, rather than describing a chooser.

parse_args

parse_args(ctx: Context, args: list[str]) -> list[str]

Hand the arguments to the root command unless a group is named.

Source code in src/texsmith/ui/cli/app.py
56
57
58
59
60
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
    """Hand the arguments to the root command unless a group is named."""
    if not args or args[0] not in self.commands:
        args = [ROOT_COMMAND, *args]
    return super().parse_args(ctx, args)

resolve_command

resolve_command(
    ctx: Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]

Resolve as Click does, but leave the root command unnamed.

Source code in src/texsmith/ui/cli/app.py
62
63
64
65
66
67
def resolve_command(
    self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
    """Resolve as Click does, but leave the root command unnamed."""
    name, command, rest = super().resolve_command(ctx, args)
    return (None if name == ROOT_COMMAND else name), command, rest

main

main() -> None

Entry point compatible with console scripts.

Source code in src/texsmith/ui/cli/app.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def main() -> None:
    """Entry point compatible with console scripts."""
    try:
        app()
    except typer.Exit:
        raise
    except KeyboardInterrupt as exc:
        if debug_enabled():
            raise
        emit_error("Operation cancelled by user.", exception=exc)
        raise typer.Exit(code=1) from exc
    except SystemExit:
        raise
    except Exception as exc:  # pragma: no cover - defensive catch-all
        from .state import get_cli_state

        state = get_cli_state()
        if state.show_tracebacks:
            from rich.traceback import Traceback

            tb = Traceback.from_exception(
                type(exc),
                exc,
                exc.__traceback__,
                show_locals=state.verbosity >= 2,
            )
            state.err_console.print(tb)
        else:
            emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

Shared CLI state management utilities.

CLIState dataclass

CLIState(
    verbosity: int = 0,
    show_tracebacks: bool = False,
    quiet: bool = False,
)

Shared state controlling CLI diagnostics.

console property

console: Console

Return a lazily instantiated stdout console.

err_console property

err_console: Console

Return a lazily instantiated stderr console.

consume_events

consume_events(name: str) -> list[dict[str, Any]]

Retrieve and clear events for the given name.

Source code in src/texsmith/ui/cli/state.py
111
112
113
def consume_events(self, name: str) -> list[dict[str, Any]]:
    """Retrieve and clear events for the given name."""
    return self.events.pop(name, [])

record_event

record_event(
    name: str, payload: Mapping[str, Any] | None = None
) -> None

Store a structured diagnostic event for later presentation.

Source code in src/texsmith/ui/cli/state.py
106
107
108
109
def record_event(self, name: str, payload: Mapping[str, Any] | None = None) -> None:
    """Store a structured diagnostic event for later presentation."""
    entry = dict(payload or {})
    self.events.setdefault(name, []).append(entry)

debug_enabled

debug_enabled() -> bool

Return whether full tracebacks should be displayed.

Source code in src/texsmith/ui/cli/state.py
273
274
275
276
277
278
def debug_enabled() -> bool:
    """Return whether full tracebacks should be displayed."""
    try:
        return get_cli_state(create=False).show_tracebacks
    except RuntimeError:
        return False

emit_error

emit_error(
    message: str, *, exception: BaseException | None = None
) -> None

Log an error-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
266
267
268
269
270
def emit_error(message: str, *, exception: BaseException | None = None) -> None:
    """Log an error-level message to stderr respecting verbosity settings."""
    if exception is not None and getattr(exception, "_texsmith_logged", False):
        return
    render_message("error", message, exception=exception)

emit_warning

emit_warning(
    message: str, *, exception: BaseException | None = None
) -> None

Log a warning-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
261
262
263
def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
    """Log a warning-level message to stderr respecting verbosity settings."""
    render_message("warning", message, exception=exception)

ensure_rich_compat

ensure_rich_compat() -> None

Patch Rich stub modules provided by tests to expose required attributes.

Source code in src/texsmith/ui/cli/state.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def ensure_rich_compat() -> None:
    """Patch Rich stub modules provided by tests to expose required attributes."""
    import importlib.machinery
    import sys as _sys
    import types

    rich_mod = _sys.modules.get("rich")
    if rich_mod is None:
        return
    if getattr(rich_mod, "__spec__", None) is None:
        rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)

    is_stub = getattr(rich_mod, "__file__", None) is None

    if is_stub:
        try:
            import typer.core as typer_core

            cast(Any, typer_core).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass
        try:
            import typer.main as typer_main

            cast(Any, typer_main).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass

    if not hasattr(rich_mod, "box"):
        box_module = types.ModuleType("rich.box")
        cast(Any, box_module).SQUARE = object()
        cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
        cast(Any, box_module).SIMPLE = object()
        cast(Any, rich_mod).box = box_module
        _sys.modules.setdefault("rich.box", box_module)

exception_details

exception_details(
    exception: BaseException | None,
    message: str,
    *,
    verbosity: int,
) -> list[str]

The lines -v adds under a message caused by exception (none below -v).

Source code in src/texsmith/ui/cli/state.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def exception_details(
    exception: BaseException | None, message: str, *, verbosity: int
) -> list[str]:
    """The lines ``-v`` adds under a message caused by ``exception`` (none below ``-v``)."""
    if exception is None or verbosity < 1:
        return []
    lines: list[str] = []
    detail = str(exception).strip()
    if detail and detail not in message:
        lines.append(detail)
    lines.append(f"type: {type(exception).__name__}")
    notes = getattr(exception, "__notes__", None)
    if notes:
        lines.extend(str(note) for note in notes)
    if verbosity >= 2:
        chain = _exception_chain(exception)
        if chain:
            lines.append("caused by:")
            lines.extend(f"  {entry}" for entry in chain)
    if verbosity >= 3:
        lines.append(f"repr: {exception!r}")
    return lines

get_cli_state

get_cli_state(
    ctx: Context | Context | None = None,
    *,
    create: bool = True,
) -> CLIState

Return the CLI state associated with the active Typer context.

Source code in src/texsmith/ui/cli/state.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_cli_state(
    ctx: typer.Context | click.Context | None = None,
    *,
    create: bool = True,
) -> CLIState:
    """Return the CLI state associated with the active Typer context."""
    if ctx is None:
        try:
            candidate = click.get_current_context(silent=True)
        except RuntimeError:
            candidate = None
        if isinstance(candidate, typer.Context):
            ctx = candidate

    state: CLIState | None = None

    if isinstance(ctx, typer.Context):
        current_ctx: typer.Context | None = ctx
        while current_ctx is not None:
            obj = getattr(current_ctx, "obj", None)
            if isinstance(obj, CLIState):
                state = obj
                break
            current_ctx = getattr(current_ctx, "parent", None)
        if state is None and create:
            state = CLIState()
            current_ctx = ctx
            current_ctx.obj = state
        if state is not None:
            _STATE_VAR.set(state)

    if state is None:
        fallback = _STATE_VAR.get(None)
        if fallback is None:
            if not create:
                raise RuntimeError("CLI state is not initialised for this context.")
            fallback = CLIState()
            _STATE_VAR.set(fallback)
        state = fallback

    return state

render_message

render_message(
    level: str,
    message: str,
    *,
    exception: BaseException | None = None,
) -> None

Render a formatted message to the console, including optional diagnostics.

Source code in src/texsmith/ui/cli/state.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def render_message(
    level: str,
    message: str,
    *,
    exception: BaseException | None = None,
) -> None:
    """Render a formatted message to the console, including optional diagnostics."""
    state = get_cli_state()

    if level == "info":
        # Use a neutral console log for info to align with pipeline logging.
        console = state.console
        if getattr(console, "log", None):
            console.log(message)
        return

    from rich.text import Text

    style = "red" if level == "error" else "yellow"
    label_style = f"bold {style}"
    if hasattr(Text, "assemble"):
        text = Text.assemble((f"{level}: ", label_style), (message, style))
    else:  # pragma: no cover - stub Text fallback
        text = Text()
        text.append(f"{level}: ")
        text.append(message)
    extra_lines = exception_details(exception, message, verbosity=state.verbosity)

    if extra_lines:
        if hasattr(text, "append"):
            text.append("\n")
            if hasattr(Text, "assemble"):
                text.append("\n".join(extra_lines), style=style)
            else:  # pragma: no cover - stub Text fallback
                text.append("\n".join(extra_lines))
        else:  # pragma: no cover - fallback if text is a plain string
            text = f"{text}\n" + "\n".join(extra_lines)

    console = state.err_console if level != "info" else state.console
    if type(console).__name__.startswith("_Stub"):  # pragma: no cover - stub Console fallback
        target = sys.stderr if level != "info" else sys.stdout
        print(text if isinstance(text, str) else str(text), file=target)
    else:
        console.print(text)

set_cli_state

set_cli_state(
    *,
    ctx: Context | None = None,
    verbosity: int | None = None,
    debug: bool | None = None,
    quiet: bool | None = None,
) -> CLIState

Update the CLI state, returning the current instance.

Source code in src/texsmith/ui/cli/state.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def set_cli_state(
    *,
    ctx: typer.Context | None = None,
    verbosity: int | None = None,
    debug: bool | None = None,
    quiet: bool | None = None,
) -> CLIState:
    """Update the CLI state, returning the current instance."""
    state = get_cli_state(ctx)
    if verbosity is not None:
        state.verbosity = max(0, verbosity)
    if debug is not None:
        state.show_tracebacks = debug
    if quiet is not None:
        state.quiet = quiet
    return state

Auxiliary helpers used by CLI commands.

determine_output_target

determine_output_target(
    template_selected: bool,
    documents: list[Path],
    output_option: Path | None,
) -> tuple[str, Path | None]

Infer where conversion output should be written based on CLI arguments.

Source code in src/texsmith/ui/cli/utils.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def determine_output_target(
    template_selected: bool,
    documents: list[Path],
    output_option: Path | None,
) -> tuple[str, Path | None]:
    """Infer where conversion output should be written based on CLI arguments."""
    if template_selected:
        if output_option is None:
            base_dir = documents[0].parent if documents else Path()
            return "template", (base_dir / "build")
        suffix = output_option.suffix.lower()
        if suffix == ".pdf":
            return "template-pdf", output_option
        if output_option.exists() and output_option.is_file():
            raise typer.BadParameter("Template output must be a directory.")
        if suffix:
            raise typer.BadParameter("Template output must be a directory path.")
        return "template", output_option

    if output_option is None:
        return "stdout", None

    if output_option.exists() and output_option.is_dir():
        return "directory", output_option

    if output_option.suffix:
        return "file", output_option

    return "directory", output_option

looks_like_document_path

looks_like_document_path(candidate: str) -> bool

Return True when the string names a source the reader accepts.

Source code in src/texsmith/ui/cli/utils.py
55
56
57
def looks_like_document_path(candidate: str) -> bool:
    """Return True when the string names a source the reader accepts."""
    return is_markdown(candidate)

normalise_selector

normalise_selector(selector: str | None) -> str | None

Strip surrounding quotes and whitespace from user-provided selectors.

Source code in src/texsmith/ui/cli/utils.py
60
61
62
63
64
65
66
67
def normalise_selector(selector: str | None) -> str | None:
    """Strip surrounding quotes and whitespace from user-provided selectors."""
    if selector is None:
        return None
    candidate = selector.strip()
    if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in {'"', "'"}:
        candidate = candidate[1:-1].strip()
    return candidate or None

organise_slot_overrides

organise_slot_overrides(
    values: Iterable[str] | None, documents: list[Path]
) -> tuple[
    dict[Path, dict[str, str]],
    dict[Path, list[SlotAssignment]],
]

Produce slot selector overrides and assignments for downstream processing.

Source code in src/texsmith/ui/cli/utils.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def organise_slot_overrides(
    values: Iterable[str] | None,
    documents: list[Path],
) -> tuple[dict[Path, dict[str, str]], dict[Path, list[SlotAssignment]]]:
    """Produce slot selector overrides and assignments for downstream processing."""
    tokens = parse_cli_slot_tokens(values)
    assignments = resolve_slot_assignments(tokens, documents)

    slot_overrides: dict[Path, dict[str, str]] = {}
    for doc, entries in assignments.items():
        if not entries:
            continue
        mapping = slot_overrides.setdefault(doc, {})
        for entry in entries:
            if entry.selector is not None:
                mapping[entry.slot] = entry.selector

    return slot_overrides, assignments

parse_cli_slot_tokens

parse_cli_slot_tokens(
    values: Iterable[str] | None,
) -> list[tuple[str, str | None, str | None, str]]

Tokenise slot overrides into (slot, path, selector, raw) tuples.

Source code in src/texsmith/ui/cli/utils.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def parse_cli_slot_tokens(
    values: Iterable[str] | None,
) -> list[tuple[str, str | None, str | None, str]]:
    """Tokenise slot overrides into (slot, path, selector, raw) tuples."""
    tokens: list[tuple[str, str | None, str | None, str]] = []
    if not values:
        return tokens

    for raw in values:
        if not isinstance(raw, str):
            continue
        entry = raw.strip()
        if not entry:
            continue
        if ":" not in entry:
            raise typer.BadParameter(
                f"Invalid slot override '{raw}', expected format "
                f"'slot:selector' or 'slot:file[:selector]'."
            )
        slot_name, remainder = entry.split(":", 1)
        slot_name = slot_name.strip()
        remainder = remainder.strip()
        if not slot_name or not remainder:
            raise typer.BadParameter(
                f"Invalid slot override '{raw}', expected format "
                f"'slot:selector' or 'slot:file[:selector]'."
            )

        path_hint: str | None
        selector_value: str | None
        if ":" in remainder:
            path_part, selector_part = remainder.split(":", 1)
            path_part = path_part.strip()
            selector_value = normalise_selector(selector_part)
            path_hint = path_part or None
        else:
            if looks_like_document_path(remainder):
                path_hint = remainder
                selector_value = None
            else:
                path_hint = None
                selector_value = normalise_selector(remainder)

        tokens.append((slot_name, path_hint, selector_value, raw))

    return tokens

resolve_slot_assignments

resolve_slot_assignments(
    tokens: list[tuple[str, str | None, str | None, str]],
    documents: list[Path],
) -> dict[Path, list[SlotAssignment]]

Resolve parsed slot tokens against provided documents.

Source code in src/texsmith/ui/cli/utils.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def resolve_slot_assignments(
    tokens: list[tuple[str, str | None, str | None, str]],
    documents: list[Path],
) -> dict[Path, list[SlotAssignment]]:
    """Resolve parsed slot tokens against provided documents."""
    assignments: dict[Path, list[SlotAssignment]] = {doc: [] for doc in documents}
    if not tokens:
        return assignments

    resolved_index = {doc.resolve(): doc for doc in documents}
    name_index: dict[str, list[Path]] = {}
    for doc in documents:
        name_index.setdefault(doc.name, []).append(doc)

    for slot_name, path_hint, selector_value, raw in tokens:
        target_doc: Path | None = None
        if path_hint is None:
            if len(documents) == 1:
                target_doc = documents[0]
            else:
                raise typer.BadParameter(
                    f"slot override '{raw}' requires a document "
                    "path when multiple inputs are provided."
                )
        else:
            candidate_path = Path(path_hint)
            resolved_candidate: Path | None = None
            try:
                base = (
                    candidate_path if candidate_path.is_absolute() else Path.cwd() / candidate_path
                )
                resolved_candidate = base.resolve()
            except OSError:
                resolved_candidate = None

            if resolved_candidate is not None and resolved_candidate in resolved_index:
                target_doc = resolved_index[resolved_candidate]
            else:
                matches = name_index.get(candidate_path.name, [])
                if len(matches) == 1:
                    target_doc = matches[0]
                elif len(matches) > 1:
                    raise typer.BadParameter(
                        f"slot override '{raw}' is ambiguous; multiple "
                        f"documents match '{candidate_path.name}'."
                    )

        if target_doc is None:
            raise typer.BadParameter(f"slot override '{raw}' does not match any provided document.")

        selector_clean = selector_value
        include_document = False
        if selector_clean is None:
            include_document = True
        else:
            token_lower = selector_clean.strip().lower()
            if token_lower in {"*", DOCUMENT_SELECTOR_SENTINEL.lower()}:
                include_document = True
                selector_clean = None

        assignments[target_doc].append(
            SlotAssignment(
                slot=slot_name, selector=selector_clean, include_document=include_document
            )
        )

    return assignments

write_output_file

write_output_file(target: Path, content: str) -> None

Persist LaTeX content to disk, creating parent directories as needed.

Source code in src/texsmith/ui/cli/utils.py
46
47
48
49
50
51
52
def write_output_file(target: Path, content: str) -> None:
    """Persist LaTeX content to disk, creating parent directories as needed."""
    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
    except OSError as exc:  # pragma: no cover - filesystem errors
        raise OSError(f"Failed to write LaTeX output to '{target}': {exc}") from exc

Bibliography-related CLI helpers.

build_reference_panel

build_reference_panel(
    reference: Mapping[str, object],
) -> Panel

Create a Rich panel that visualises a single bibliography entry.

Source code in src/texsmith/ui/cli/bibliography.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def build_reference_panel(reference: Mapping[str, object]) -> Panel:
    """Create a Rich panel that visualises a single bibliography entry."""
    from rich import box
    from rich.panel import Panel
    from rich.table import Table

    raw_fields = reference.get("fields")
    fields: dict[str, Any]
    if isinstance(raw_fields, Mapping):
        fields = {str(key): value for key, value in raw_fields.items()}
    else:
        fields = {}
    grid = Table.grid(padding=(0, 1))
    grid.add_column(style="bold green", no_wrap=True)
    grid.add_column()

    def _pop_field(*keys: str) -> str | None:
        for key in keys:
            value = fields.pop(key, None)
            if isinstance(value, bytes):
                text = value.decode("utf-8", errors="ignore").strip()
                if text:
                    return text
            elif isinstance(value, str):
                text = value.strip()
                if text:
                    return text
            elif value is not None:
                return str(value)
        return None

    def _add_field(label: str, value: object) -> None:
        if value is None:
            return
        if isinstance(value, str) and not value.strip():
            return
        grid.add_row(label, str(value))

    title = _pop_field("title")
    _add_field("Title", title)

    year = _pop_field("year")
    _add_field("Year", year)

    journal = _pop_field("journal", "booktitle")
    _add_field("Journal", journal)

    persons_block = reference.get("persons")
    if isinstance(persons_block, Mapping):
        authors = persons_block.get("author")
        if isinstance(authors, Iterable):
            cast_authors = cast(Sequence[Mapping[str, object]], tuple(authors))
            _add_field("Authors", format_person_list(cast_authors))

    sources = [
        str(Path(str(path))) for path in _iterable_items(reference.get("source_files")) if path
    ]
    if sources:
        formatted_sources = ", ".join(sources)
        _add_field("Sources", formatted_sources)

    for key, value in sorted(fields.items()):
        _add_field(key.title(), value)

    key = str(reference.get("key", "Reference"))
    entry_type = str(reference.get("type", "reference"))
    title = f"{key} ({entry_type})"
    return Panel(grid, title=title, box=box.SIMPLE)

format_bibliography_person

format_bibliography_person(
    person: Mapping[str, object],
) -> str

Render a bibliography person dictionary into a readable string.

Source code in src/texsmith/ui/cli/bibliography.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def format_bibliography_person(person: Mapping[str, object]) -> str:
    """Render a bibliography person dictionary into a readable string."""
    parts: list[str] = []
    for field in ("first", "middle", "prelast", "last", "lineage"):
        value = person.get(field)
        if isinstance(value, str):
            text = value.strip()
            if text:
                parts.append(text)
            continue
        for segment in _iterable_items(value):
            if segment:
                parts.append(str(segment))

    text = " ".join(part for part in parts if part)
    if text:
        return text
    fallback = person.get("text")
    return str(fallback).strip() if isinstance(fallback, str) else ""

format_person_list

format_person_list(
    persons: Iterable[Mapping[str, object]],
) -> str

Join a sequence of person dictionaries into a comma-separated string.

Source code in src/texsmith/ui/cli/bibliography.py
53
54
55
56
def format_person_list(persons: Iterable[Mapping[str, object]]) -> str:
    """Join a sequence of person dictionaries into a comma-separated string."""
    names = [format_bibliography_person(person) for person in persons]
    return ", ".join(name for name in names if name)

print_bibliography_overview

print_bibliography_overview(
    collection: BibliographyCollection,
) -> None

Render a formatted summary of bibliography files, issues, and entries.

Source code in src/texsmith/ui/cli/bibliography.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def print_bibliography_overview(collection: BibliographyCollection) -> None:
    """Render a formatted summary of bibliography files, issues, and entries."""
    ensure_rich_compat()
    try:
        from rich import box
        from rich.table import Table
        from rich.text import Text
    except ImportError:  # pragma: no cover - fallback when Rich is unavailable
        _print_bibliography_plain(collection)
        return

    console = get_cli_state().console
    references = collection.list_references()

    def _summarise_sources() -> tuple[Counter[Path], int, int, int]:
        per_file: Counter[Path] = Counter()
        frontmatter = 0
        doi = 0
        for reference in references:
            raw_sources = reference.get("source_files") or []
            sources = {Path(str(src)) for src in raw_sources if src}
            if not sources:
                continue
            for src in sources:
                per_file[src] += 1
            names = {src.name.lower() for src in sources}
            if any("frontmatter" in name for name in names):
                frontmatter += 1
            if any("doi" in name for name in names):
                doi += 1
        total = len(references)
        return per_file, total, frontmatter, doi

    per_file_counts, total_entries, frontmatter_entries, doi_entries = _summarise_sources()

    stats = collection.file_stats
    if stats:
        stats_table = Table(
            title="Bibliography Files",
            box=box.SQUARE,
            show_edge=True,
            header_style="bold cyan",
        )
        stats_table.add_column("File", overflow="fold")
        stats_table.add_column("Entries", justify="right")
        for file_path, entry_count in stats:
            stats_table.add_row(str(file_path), str(entry_count))
        stats_table.add_row(
            Text("Total", style="bold"), Text(str(sum(count for _, count in stats)))
        )
        console.print(stats_table)

    if collection.issues:
        issue_table = Table(
            title="Warnings",
            box=box.SQUARE,
            header_style="bold cyan",
            show_edge=True,
        )
        issue_table.add_column("Key", style="yellow", no_wrap=True)
        issue_table.add_column("Message", style="yellow")
        issue_table.add_column("Sources", style="yellow")
        for issue in collection.issues:
            issue_table.add_row(
                issue.key or "—",
                issue.message,
                str(issue.source) if issue.source else "—",
            )
        console.print(issue_table)

    if not references:
        console.print("[dim]No references found.[/]")
        summary_table = Table(
            title="Bibliography Summary",
            box=box.SQUARE,
            header_style="bold cyan",
            show_edge=True,
        )
        summary_table.add_column("Category", style="bold")
        summary_table.add_column("Count", justify="right")
        summary_table.add_row("Total entries", str(total_entries))
        if per_file_counts:
            for path, count in per_file_counts.most_common():
                summary_table.add_row(f"From {path.name}", str(count))
        summary_table.add_row("From front matter", str(frontmatter_entries))
        summary_table.add_row("From DOI fetches", str(doi_entries))
        console.print(summary_table)
        return

    for reference in references:
        panel = build_reference_panel(reference)
        console.print(panel)
        console.print()

    summary_table = Table(
        title="Bibliography Summary",
        box=box.SQUARE,
        header_style="bold cyan",
        show_edge=True,
    )
    summary_table.add_column("Category", style="bold")
    summary_table.add_column("Count", justify="right")
    summary_table.add_row("Total entries", str(total_entries))
    if per_file_counts:
        for path, count in per_file_counts.most_common():
            summary_table.add_row(f"From {path.name}", str(count))
    summary_table.add_row("From front matter", str(frontmatter_entries))
    summary_table.add_row("From DOI fetches", str(doi_entries))
    console.print(summary_table)

Implementation of the primary texsmith CLI command.

render

render(
    show_version: Annotated[
        bool,
        Option(
            "--version",
            help="Show the TeXSmith version and exit.",
            is_eager=True,
        ),
    ] = False,
    list_templates_flag: Annotated[
        bool,
        Option(
            "--list-templates",
            help="List available templates (builtin, entry-point, and local) and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    list_bibliography: Annotated[
        bool,
        Option(
            "--list-bibliography",
            help="Print bibliography details from provided .bib files and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    verbose: Annotated[
        int,
        Option(
            "--verbose",
            "-v",
            count=True,
            help="Increase CLI verbosity. Combine multiple times for additional diagnostics.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = 0,
    debug: Annotated[
        bool,
        Option(
            "--debug",
            help="Show full tracebacks when an unexpected error occurs.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    quiet: QuietOption = False,
    strict: StrictOption = False,
    deprecated: DeprecatedOption = None,
    diagnostics_json: DiagnosticsJsonOption = None,
    inputs: InputPathArgument = None,
    input_path: Annotated[
        Path | None,
        Option(
            "--input-path",
            help="Internal helper used for programmatic invocation.",
            hidden=True,
        ),
    ] = None,
    output: OutputPathOption = None,
    base_level: BaseLevelOption = str(
        _REQUEST_DEFAULTS.base_level
    ),
    strip_heading: StripHeadingOption = _REQUEST_DEFAULTS.strip_heading_all,
    no_promote_title: NoPromoteTitleOption = not _REQUEST_DEFAULTS.promote_title,
    no_title: NoTitleOption = _REQUEST_DEFAULTS.suppress_title,
    include_paths: IncludePathOption = None,
    no_copy_assets: NoCopyAssetsOption = not _REQUEST_DEFAULTS.copy_assets,
    convert_assets: ConvertAssetsOption = _REQUEST_DEFAULTS.convert_assets,
    hash_assets: HashAssetsOption = _REQUEST_DEFAULTS.hash_assets,
    http_user_agent: HttpUserAgentOption = _REQUEST_DEFAULTS.http_user_agent,
    diagrams_backend: Annotated[
        str | None,
        Option(
            "--diagrams-backend",
            metavar="BACKEND",
            help="Force the backend for diagram conversion (draw.io, mermaid): playwright, local, or docker (auto-default).",
            case_sensitive=False,
        ),
    ] = _REQUEST_DEFAULTS.diagrams_backend,
    manifest: ManifestOptionWithShort = _REQUEST_DEFAULTS.manifest,
    make_deps: MakefileDepsOption = False,
    template: TemplateOption = None,
    embed_documents: Annotated[
        bool,
        Option(
            "--embed",
            help="Embed converted documents into the main document instead linking them with \\input.",
        ),
    ] = _REQUEST_DEFAULTS.embed_documents,
    enable_fragments: EnableFragmentOption = None,
    disable_fragments: DisableFragmentOption = None,
    template_attributes: TemplateAttributeOption = None,
    debug_ir: DebugIrOption = None,
    classic_output: Annotated[
        bool,
        Option(
            "--classic-output",
            help="Display raw latexmk output without parsing.",
        ),
    ] = False,
    output_format: FormatOption = "latex",
    build_pdf: Annotated[
        bool,
        Option(
            "-b",
            "--build",
            help="Invoke latexmk after rendering to compile the resulting LaTeX project.",
        ),
    ] = False,
    engine: Annotated[
        str,
        Option(
            "-e",
            "--engine",
            help="LaTeX engine backend to use when building (tectonic, lualatex, xelatex).",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = "tectonic",
    system_tectonic: Annotated[
        bool,
        Option(
            "-S",
            "--system",
            help="Use the system Tectonic binary instead of the bundled download.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    language: LanguageOption = _REQUEST_DEFAULTS.language,
    numbering: NumberingOption = "backend",
    legacy_latex_accents: Annotated[
        bool,
        Option(
            "--legacy-latex-accents",
            help="Escape accented characters and ligatures with legacy LaTeX macros instead of emitting Unicode glyphs (defaults to Unicode output).",
        ),
    ] = _REQUEST_DEFAULTS.legacy_latex_accents,
    slots: SlotsOption = None,
    open_log: OpenLogOption = False,
    snippet_dump_dir: Annotated[
        Path | None,
        Option(
            "--dump-snippets",
            metavar="DIR",
            help="Copy snippet render sources (tex, aux files) into DIR for inspection.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = None,
    isolate_cache: Annotated[
        bool,
        Option(
            "--isolate",
            help="Use a per-render TeX cache inside the output directory instead of the shared ~/.cache/texsmith cache.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    template_info_flag: TemplateInfoOption = False,
    template_scaffold: Annotated[
        Path | None,
        Option(
            "--template-scaffold",
            metavar="DEST",
            help="Copy the selected template into DEST and exit.",
        ),
    ] = None,
    fonts_info: FontsInfoOption = False,
    print_context: Annotated[
        bool,
        Option(
            "--print-context",
            help="Print resolved template context emitters/consumers and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
) -> None

Convert Markdown documents into LaTeX or Typst sources and optionally build PDFs.

Source code in src/texsmith/ui/cli/commands/render.py
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
def render(
    show_version: Annotated[
        bool,
        typer.Option(
            "--version",
            help="Show the TeXSmith version and exit.",
            is_eager=True,
        ),
    ] = False,
    list_templates_flag: Annotated[
        bool,
        typer.Option(
            "--list-templates",
            help="List available templates (builtin, entry-point, and local) and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    list_bibliography: Annotated[
        bool,
        typer.Option(
            "--list-bibliography",
            help="Print bibliography details from provided .bib files and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    verbose: Annotated[
        int,
        typer.Option(
            "--verbose",
            "-v",
            count=True,
            help=("Increase CLI verbosity. Combine multiple times for additional diagnostics."),
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = 0,
    debug: Annotated[
        bool,
        typer.Option(
            "--debug",
            help="Show full tracebacks when an unexpected error occurs.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
    quiet: QuietOption = False,
    strict: StrictOption = False,
    deprecated: DeprecatedOption = None,
    diagnostics_json: DiagnosticsJsonOption = None,
    inputs: InputPathArgument = None,
    input_path: Annotated[
        Path | None,
        typer.Option(
            "--input-path",
            help="Internal helper used for programmatic invocation.",
            hidden=True,
        ),
    ] = None,
    output: OutputPathOption = None,
    base_level: BaseLevelOption = str(_REQUEST_DEFAULTS.base_level),
    strip_heading: StripHeadingOption = _REQUEST_DEFAULTS.strip_heading_all,
    no_promote_title: NoPromoteTitleOption = not _REQUEST_DEFAULTS.promote_title,
    no_title: NoTitleOption = _REQUEST_DEFAULTS.suppress_title,
    include_paths: IncludePathOption = None,
    no_copy_assets: NoCopyAssetsOption = not _REQUEST_DEFAULTS.copy_assets,
    convert_assets: ConvertAssetsOption = _REQUEST_DEFAULTS.convert_assets,
    hash_assets: HashAssetsOption = _REQUEST_DEFAULTS.hash_assets,
    http_user_agent: HttpUserAgentOption = _REQUEST_DEFAULTS.http_user_agent,
    diagrams_backend: Annotated[
        str | None,
        typer.Option(
            "--diagrams-backend",
            metavar="BACKEND",
            help="Force the backend for diagram conversion (draw.io, mermaid): playwright, local, or docker (auto-default).",
            case_sensitive=False,
        ),
    ] = _REQUEST_DEFAULTS.diagrams_backend,
    manifest: ManifestOptionWithShort = _REQUEST_DEFAULTS.manifest,
    make_deps: MakefileDepsOption = False,
    template: TemplateOption = None,
    embed_documents: Annotated[
        bool,
        typer.Option(
            "--embed",
            help="Embed converted documents into the main document instead linking them with \\input.",
        ),
    ] = _REQUEST_DEFAULTS.embed_documents,
    enable_fragments: EnableFragmentOption = None,
    disable_fragments: DisableFragmentOption = None,
    template_attributes: TemplateAttributeOption = None,
    debug_ir: DebugIrOption = None,
    classic_output: Annotated[
        bool,
        typer.Option(
            "--classic-output",
            help=("Display raw latexmk output without parsing."),
        ),
    ] = False,
    output_format: FormatOption = "latex",
    build_pdf: Annotated[
        bool,
        typer.Option(
            "-b",
            "--build",
            help="Invoke latexmk after rendering to compile the resulting LaTeX project.",
        ),
    ] = False,
    engine: Annotated[
        str,
        typer.Option(
            "-e",
            "--engine",
            help="LaTeX engine backend to use when building (tectonic, lualatex, xelatex).",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = "tectonic",
    system_tectonic: Annotated[
        bool,
        typer.Option(
            "-S",
            "--system",
            help="Use the system Tectonic binary instead of the bundled download.",
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    language: LanguageOption = _REQUEST_DEFAULTS.language,
    numbering: NumberingOption = "backend",
    legacy_latex_accents: Annotated[
        bool,
        typer.Option(
            "--legacy-latex-accents",
            help=(
                "Escape accented characters and ligatures with legacy LaTeX macros instead of "
                "emitting Unicode glyphs (defaults to Unicode output)."
            ),
        ),
    ] = _REQUEST_DEFAULTS.legacy_latex_accents,
    slots: SlotsOption = None,
    open_log: OpenLogOption = False,
    snippet_dump_dir: Annotated[
        Path | None,
        typer.Option(
            "--dump-snippets",
            metavar="DIR",
            help="Copy snippet render sources (tex, aux files) into DIR for inspection.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = None,
    isolate_cache: Annotated[
        bool,
        typer.Option(
            "--isolate",
            help=(
                "Use a per-render TeX cache inside the output directory instead of the shared "
                "~/.cache/texsmith cache."
            ),
            rich_help_panel=OUTPUT_PANEL,
        ),
    ] = False,
    template_info_flag: TemplateInfoOption = False,
    template_scaffold: Annotated[
        Path | None,
        typer.Option(
            "--template-scaffold",
            metavar="DEST",
            help="Copy the selected template into DEST and exit.",
        ),
    ] = None,
    fonts_info: FontsInfoOption = False,
    print_context: Annotated[
        bool,
        typer.Option(
            "--print-context",
            help="Print resolved template context emitters/consumers and exit.",
            rich_help_panel=DIAGNOSTICS_PANEL,
        ),
    ] = False,
) -> None:
    """Convert Markdown documents into LaTeX or Typst sources and optionally build PDFs."""

    ctx = click.get_current_context(silent=True)
    typer_ctx = ctx if isinstance(ctx, typer.Context) else None

    if typer_ctx is not None and typer_ctx.resilient_parsing:
        return

    if show_version:
        typer.echo(get_version())
        raise typer.Exit()

    state = set_cli_state(ctx=typer_ctx, verbosity=verbose, debug=debug, quiet=quiet)
    output_format = (output_format or "latex").strip().lower()
    if output_format not in {"latex", "typst"}:
        raise typer.BadParameter("--format must be 'latex' or 'typst'.")
    # The Typst backend emits a ``.typ`` via the shared IR. With a template it
    # wraps the body in the template's [typst.template] scaffolding; without one
    # it emits a standalone document. It does not use the LaTeX fragment/engine
    # machinery, so the template *info/scaffold* flags are unsupported here.
    if output_format == "typst" and (template_info_flag or template_scaffold is not None):
        raise typer.BadParameter(
            "--format typst does not support --template-info/--template-scaffold."
        )
    numbering = (numbering or "backend").strip().lower()
    if numbering not in NUMBERING_MODES:
        raise typer.BadParameter("--numbering must be 'backend' or 'tmark'.")
    if deprecated is not None:
        deprecated = deprecated.strip().lower()
        if deprecated not in DEPRECATED_LEVELS:
            raise typer.BadParameter("--deprecated must be 'warning', 'info' or 'off'.")
    if snippet_dump_dir is not None:
        os.environ["TEXSMITH_SNIPPET_DUMP_DIR"] = str(snippet_dump_dir)

    if list_templates_flag:
        list_templates()
        raise typer.Exit()

    document_paths = list(inputs or [])
    if input_path is not None:
        if document_paths:
            raise typer.BadParameter("Provide either positional inputs or --input-path, not both.")
        document_paths = [input_path]

    if not document_paths:
        stdin_document = _read_stdin_document()
        if stdin_document is not None:
            document_paths = [stdin_document]

    try:
        split_result = _SERVICE.split_inputs(document_paths)
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

    document_paths = split_result.documents
    bibliography_files = split_result.bibliography_files
    shared_front_matter = split_result.front_matter
    shared_front_matter_paths = split_result.front_matter_paths

    template_requested = template_info_flag or template_scaffold

    if list_bibliography:
        collection = BibliographyCollection()
        if bibliography_files:
            collection.load_files(bibliography_files)
        print_bibliography_overview(collection)
        raise typer.Exit()

    if not document_paths:
        if template_requested:
            identifier = template or "article"
            if template_info_flag:
                show_template_info(identifier)
            if template_scaffold:
                scaffold_template(identifier, template_scaffold)
            raise typer.Exit()
        raise typer.BadParameter(
            "Provide a Markdown (.md) source document or pipe content via stdin."
        )

    copy_assets = not no_copy_assets
    primary_front_matter: Mapping[str, Any] | None = shared_front_matter
    first_document = document_paths[0] if document_paths else None
    if primary_front_matter is None and first_document is not None:
        front_matter = _load_front_matter(first_document)
        if front_matter:
            primary_front_matter = front_matter

    fm_payload: dict[str, Any] = {}
    if isinstance(primary_front_matter, Mapping):
        fm_payload = dict(primary_front_matter)
        try:
            normalise_press_metadata(fm_payload)
        except PressMetadataError as exc:
            raise typer.BadParameter(str(exc)) from exc

    # The template has to be known before the two flags that report on one and
    # exit; everything else the flags imply is settled by ``resolve_render_plan``.
    template_param_source = ctx.get_parameter_source("template") if ctx else None
    no_promote_param_source = ctx.get_parameter_source("no_promote_title") if ctx else None
    if template is None and template_param_source in {None, ParameterSource.DEFAULT}:
        template = declared_template(primary_front_matter)

    if template_requested:
        identifier = template or "article"
        if template_info_flag:
            show_template_info(identifier)
        if template_scaffold:
            scaffold_template(identifier, template_scaffold)
        raise typer.Exit()

    attribute_overrides = _parse_template_attributes(template_attributes)
    has_attributes = bool(attribute_overrides)
    if attribute_overrides:
        try:
            normalise_press_metadata(attribute_overrides)
        except PressMetadataError as exc:
            raise typer.BadParameter(str(exc)) from exc
    if engine:
        attribute_overrides.setdefault("_texsmith_latex_engine", engine)
    if numbering != "backend":
        # Read by both IR backends (``core.conversion.resolution.numbering_mode``).
        attribute_overrides[NUMBERING_OVERRIDE_KEY] = numbering

    try:
        plan = resolve_render_plan(
            front_matter=fm_payload,
            template=template,
            build_pdf=build_pdf,
            output=output,
            output_format=output_format,
            print_context=print_context,
            has_attributes=has_attributes,
            no_title=no_title,
            strip_heading=strip_heading,
            no_promote_title=no_promote_title,
            no_promote_defaulted=no_promote_param_source in {None, ParameterSource.DEFAULT},
            base_level=base_level,
            base_level_defaulted=(ctx.get_parameter_source("base_level") if ctx else None)
            in {None, ParameterSource.DEFAULT},
            classic_output=classic_output,
            open_log=open_log,
            make_deps=make_deps,
            strict=strict,
            deprecated=deprecated,
            template_options=attribute_overrides,
            ci_runner=os.environ.get("ACT") == "true",
        )
    except RenderOptionError as exc:
        raise typer.BadParameter(str(exc)) from exc

    template = plan.template
    template_selected = plan.template_selected
    build_pdf = plan.build_pdf
    classic_output = plan.classic_output
    promote_title = plan.promote_title
    numbered = plan.numbered
    strict = plan.strict
    deprecated = plan.deprecated
    resolved_base_level = plan.base_level
    for notice in plan.notices:
        typer.echo(notice)

    if attribute_overrides:
        state.record_event("template_attributes", {"values": attribute_overrides})

    try:
        _, slot_assignments = organise_slot_overrides(slots, document_paths)
    except (typer.BadParameter, ValueError) as exc:
        raise typer.BadParameter(str(exc)) from exc

    state_slot_rows: list[dict[str, object]] = []
    for doc_path, entries in slot_assignments.items():
        for entry in entries:
            state_slot_rows.append(
                {
                    "document": _format_path_for_event(doc_path),
                    "slot": entry.slot,
                    "selector": entry.selector,
                    "include_document": entry.include_document,
                }
            )
    if state_slot_rows:
        state.record_event("slot_assignments", {"entries": state_slot_rows})

    emitter = _RenderEmitter(state=state, debug_enabled=debug_enabled(), deprecated=deprecated)
    presented_diagnostics = 0

    def _flush_diagnostics() -> None:
        """Close a phase: event lines, the diagnostics summary, the JSON dump, ``--strict``.

        Called at every exit of the command and, when building, once more
        before the engine runs so the ``.tex`` is there to inspect when
        ``--strict`` stops the run.
        """
        nonlocal presented_diagnostics
        lines: list[str] = list(consume_event_diagnostics(state))
        for line in lines:
            typer.echo(line)
        if len(emitter.sink) > presented_diagnostics:
            presented_diagnostics = len(emitter.sink)
            present_diagnostics_summary(state, emitter.sink)
        if diagnostics_json is not None:
            try:
                write_diagnostics_json(diagnostics_json, emitter.sink)
            except OSError as exc:
                emit_error(f"Failed to write diagnostics to '{diagnostics_json}': {exc}", exc)
                raise typer.Exit(code=1) from exc
        if strict and emitter.sink.strict_failed():
            emit_error("Diagnostics were recorded and --strict is on.")
            raise typer.Exit(code=1)

    debug_snapshot = debug_ir if debug_ir is not None else debug_enabled()

    output_mode, output_target = determine_output_target(template_selected, document_paths, output)
    resolved_output_target = output_target.resolve() if output_target is not None else None

    temp_render_dir: Path | None = None
    cleanup_render_dir = False
    cleanup_render_dir_path: Path | None = None
    final_pdf_target: Path | None = None

    if template_selected:
        if output_mode == "template-pdf":
            temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
            typer.echo(f"Using temporary output directory: {temp_render_dir}")
            cleanup_render_dir = True
            cleanup_render_dir_path = temp_render_dir
            final_pdf_target = resolved_output_target
        elif build_pdf and output_mode == "template" and output is None:
            # Keyed on the absence of ``-o`` itself, not on Typer's parameter
            # source: that source is ``None`` whenever the command runs without
            # a click context, which used to route an explicit ``-o dir`` to a
            # temporary directory and drop the PDF in the current directory.
            temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
            typer.echo(f"Using temporary output directory: {temp_render_dir}")
            cleanup_render_dir = True
            cleanup_render_dir_path = temp_render_dir
            primary_name = document_paths[0].stem if document_paths else "texsmith"
            final_pdf_target = Path.cwd() / f"{primary_name}.pdf"

    render_dir_path: Path | None = None
    if template_selected:
        render_dir_path = temp_render_dir or resolved_output_target
    elif output_mode == "directory":
        render_dir_path = resolved_output_target

    if template_selected and render_dir_path is None:
        raise typer.BadParameter("Unable to resolve template output directory.")

    if not embed_documents and template_selected and len(document_paths) == 1:
        embed_documents = True

    request_render_dir = render_dir_path

    request = ConversionRequest(
        copy_assets=copy_assets,
        convert_assets=convert_assets,
        hash_assets=hash_assets,
        manifest=manifest,
        persist_debug_ir=bool(debug_snapshot),
        language=language,
        http_user_agent=http_user_agent,
        legacy_latex_accents=legacy_latex_accents,
        diagrams_backend=diagrams_backend.lower() if isinstance(diagrams_backend, str) else None,
        documents=document_paths,
        bibliography_files=bibliography_files,
        front_matter=shared_front_matter,
        front_matter_paths=shared_front_matter_paths,
        slot_assignments=slot_assignments,
        include_paths=list(include_paths or ()),
        base_level=resolved_base_level,
        strip_heading_all=strip_heading if build_pdf else False,
        strip_heading_first_document=False if build_pdf else strip_heading,
        promote_title=promote_title,
        suppress_title=no_title,
        numbered=numbered,
        template=template,
        render_dir=request_render_dir,
        template_options=attribute_overrides,
        embed_documents=embed_documents,
        enable_fragments=enable_fragments or [],
        disable_fragments=disable_fragments or [],
        emitter=emitter,
    )
    state.record_event(
        "conversion_settings",
        {
            "copy_assets": copy_assets,
            "convert_assets": convert_assets,
            "hash_assets": hash_assets,
            "manifest": manifest,
        },
    )

    try:
        prepared = _SERVICE.prepare_documents(request)
    except UnsupportedInputError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

    if output_format == "typst":
        typst_output_dir: Path | None = None
        if output_mode in {"directory", "template"}:
            typst_output_dir = resolved_output_target
        elif output_mode == "file" and resolved_output_target is not None:
            typst_output_dir = resolved_output_target.parent

        try:
            payload = render_typst_documents(
                prepared.documents, request, output_dir=typst_output_dir, emitter=emitter
            )
        except (ConversionError, TemplateError) as exc:
            # ``raise_conversion_error`` already put the record in the sink
            # and marked the exception as logged, so the message reaches the
            # console only if this exit flushes like every other one.
            _flush_diagnostics()
            emit_error(str(exc), exception=exc)
            raise typer.Exit(code=1) from exc

        if output_mode == "stdout":
            typer.echo(payload)
            _flush_diagnostics()
            return

        if output_mode == "file":
            if resolved_output_target is None:
                raise typer.BadParameter("Output path is required when writing Typst to a file.")
            target = resolved_output_target
        elif output_mode in {"directory", "template"}:
            if resolved_output_target is None:
                raise typer.BadParameter("Output directory is required when writing Typst files.")
            # Several documents make one ``main.typ``, as they make one ``main.tex``.
            stem = (
                prepared.documents[0].source_path.stem if len(prepared.documents) == 1 else "main"
            )
            target = resolved_output_target / f"{stem}.typ"
        else:
            raise RuntimeError(f"Unsupported output mode '{output_mode}' for Typst output.")
        try:
            write_output_file(target, payload)
        except OSError as exc:
            emit_error(str(exc), exception=exc)
            raise typer.Exit(code=1) from exc
        typer.echo(f"Wrote {target}")
        if build_pdf:
            ok, message = build_typst_pdf(target)
            typer.echo(message)
            if not ok and debug_enabled():
                raise typer.Exit(code=1)
        _flush_diagnostics()
        return

    engine_env_key = "TEXSMITH_SELECTED_ENGINE"
    previous_engine_value = os.environ.get(engine_env_key)
    if engine:
        os.environ[engine_env_key] = engine
    else:
        os.environ.pop(engine_env_key, None)
    try:
        response = _SERVICE.execute(request, prepared=prepared)
    except (TemplateError, ConversionError) as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    finally:
        if previous_engine_value is None:
            os.environ.pop(engine_env_key, None)
        else:
            os.environ[engine_env_key] = previous_engine_value

    if not template_selected:
        bundle = response.bundle

        if output_mode == "stdout":
            typer.echo(bundle.combined_output())
            _flush_diagnostics()
            return

        if output_mode == "file":
            if resolved_output_target is None:
                raise typer.BadParameter("Output path is required when writing to a file.")
            try:
                write_output_file(resolved_output_target, bundle.combined_output())
            except OSError as exc:
                emit_error(str(exc), exception=exc)
                raise typer.Exit(code=1) from exc
            present_conversion_summary(
                state=state,
                output_mode=output_mode,
                bundle=bundle,
                output_path=resolved_output_target,
                render_result=None,
            )
            _flush_diagnostics()
            return

        if output_mode == "directory":
            present_conversion_summary(
                state=state,
                output_mode=output_mode,
                bundle=bundle,
                output_path=request.render_dir,
                render_result=None,
            )
            _flush_diagnostics()
            return

        raise RuntimeError(f"Unsupported output mode '{output_mode}'.")

    render_result = response.render_result

    render_dir = render_result.main_tex_path.parent.resolve()

    if not build_pdf:
        present_conversion_summary(
            state=state,
            output_mode="template",
            bundle=None,
            output_path=render_dir,
            render_result=render_result,
        )
        if print_context:
            present_context_attributes(state=state, render_result=render_result)
        if fonts_info:
            present_fonts_info(state, render_result)
        _flush_diagnostics()
        return

    # Engine orchestration (binary selection, command/env build, run) lives in
    # adapters.latex.build.build_pdf; the CLI keeps only presentation, the PDF copy,
    # and dependency-file emission. ``build_pdf`` takes the runner as a parameter,
    # which is the seam a test substitutes.
    # The bodies are written: ``--strict`` decides here, before the engine runs.
    _flush_diagnostics()

    engine_choice = resolve_engine(engine, render_result.template_engine)
    state.console.print(f"[bold cyan]Running {engine_choice.label}…[/]")

    try:
        engine_result: EngineResult = pdf_build.build_pdf(
            render_result,
            engine=engine,
            classic_output=classic_output,
            isolate_cache=isolate_cache,
            console=state.console,
            verbosity=state.verbosity,
            use_system_tectonic=system_tectonic,
            run_engine=run_engine_command,
        )
    except ConversionError as exc:
        emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc
    except OSError as exc:
        if debug_enabled():
            raise
        emit_error(f"Failed to execute {engine_choice.label}: {exc}", exception=exc)
        raise typer.Exit(code=1) from exc

    if engine_result.returncode != 0:
        messages = engine_result.messages or parse_latex_log(engine_result.log_path)
        present_latex_failure(
            state=state,
            log_path=engine_result.log_path,
            messages=messages,
            open_log=open_log,
        )
        emit_error(f"{engine_choice.label} exited with status {engine_result.returncode}")
        raise typer.Exit(code=engine_result.returncode)

    pdf_path = engine_result.pdf_path
    final_pdf_path = pdf_path
    dep_file_path: Path | None = None

    if final_pdf_target is not None:
        final_destination = final_pdf_target
        try:
            final_destination.parent.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            emit_error(
                f"Unable to create output directory '{final_destination.parent}': {exc}",
                exc,
            )
            raise typer.Exit(code=1) from exc
        try:
            shutil.copy2(pdf_path, final_destination)
        except OSError as exc:
            emit_error(f"Failed to write PDF to '{final_destination}': {exc}", exc)
            raise typer.Exit(code=1) from exc
        final_pdf_path = final_destination

        # The reference inventory is written next to the intermediate ``.tex``,
        # which is a temporary directory in this mode: without this it would be
        # deleted along with it, and its pages — harvested from the ``.aux`` —
        # would never reach anyone.
        _deliver_reference_inventory(render_result.main_tex_path, final_destination.parent)

    if make_deps:
        dependency_paths: set[Path] = set()
        dependency_paths.update(document_paths)
        dependency_paths.update(bibliography_files)
        dependency_paths.update(shared_front_matter_paths)
        dependency_paths.add(render_result.main_tex_path)
        dependency_paths.update(render_result.fragment_paths)
        if render_result.bibliography_path:
            dependency_paths.add(render_result.bibliography_path)
        latexmkrc_candidate = render_dir / ".latexmkrc"
        if latexmkrc_candidate.exists():
            dependency_paths.add(latexmkrc_candidate)
        dependency_paths.update(getattr(render_result, "asset_paths", []))
        dependency_paths.update(getattr(render_result, "asset_sources", []))
        for key in getattr(render_result, "asset_map", {}) or {}:
            candidate_path = Path(key)
            if candidate_path.exists():
                dependency_paths.add(candidate_path)
        try:
            dep_file_path = _write_makefile_deps(final_pdf_path, dependency_paths)
        except OSError as exc:
            emit_error(f"Failed to write dependency file: {exc}", exc)
            raise typer.Exit(code=1) from exc

    present_build_summary(state=state, render_result=render_result, pdf_path=final_pdf_path)
    if fonts_info:
        present_fonts_info(state, render_result)
    if dep_file_path is not None:
        state.console.print(f"[cyan]Dependencies written to[/] {dep_file_path}")
    _flush_diagnostics()

    if cleanup_render_dir and cleanup_render_dir_path is not None:
        shutil.rmtree(cleanup_render_dir_path, ignore_errors=True)