Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
37e614f
feat: add termynal output mode for the directive
FBumann Jun 15, 2026
4fa4f87
feat: add :scheme: and :dark_bg: options for termynal output
FBumann Jun 15, 2026
bb05ba7
fix: use module-level termynal.escape in contract test
FBumann Jun 15, 2026
bbbf410
fix: space stacked termynal blocks apart
FBumann Jun 15, 2026
9771c12
refactor: share Click-app resolution between native and termynal modes
FBumann Jun 15, 2026
52a2f50
refactor: namespace termynal plugin options as termynal_*
FBumann Jun 15, 2026
c7807bf
docs: add termynal output mode page to the site
FBumann Jun 15, 2026
663f0bf
refactor: bundle termynal options into a TermynalOptions dataclass
FBumann Jun 15, 2026
8c06717
feat: add termynal buttons/prompt/timing options
FBumann Jun 15, 2026
0703552
docs: document termynal buttons/prompt/timing options
FBumann Jun 15, 2026
0b92ff0
docs: add CHANGELOG entry for termynal output mode
FBumann Jun 15, 2026
7e613cd
test: cover flat termynal plugin config threading through on_config
FBumann Jun 15, 2026
7df916d
feat: add termynal subcommands depth option; default to root-only
FBumann Jun 15, 2026
0662343
feat: add termynal :command: option to render a specific subcommand
FBumann Jun 15, 2026
e08f368
test: cover arbitrary-depth termynal command paths and recursion
FBumann Jun 15, 2026
6d43595
fix: guard typer's private rich-console hook with a fallback and drif…
FBumann Jun 16, 2026
debc49d
fix: keep multi-word termynal prompts whole; cover directive coercion…
FBumann Jun 16, 2026
5ee8b09
test: move termynal directive-helper tests out of test_plugin.py
FBumann Jun 16, 2026
3a50276
docs: document serving termynal under Zensical via extra_css/extra_ja…
FBumann Jun 16, 2026
43e2bb6
style: apply ruff-format to test_termynal_render.py
FBumann Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `termynal` output mode: render a CLI's `--help` as an animated, colored [termynal](https://github.com/termynal/termynal.py) terminal instead of Markdown tables, enabled per block (`:termynal: true`) or globally (`termynal: true`). The app is introspected in-process (no subprocess) and its colored `--help` is converted to termynal markup; by default only the root command is rendered. Requires the optional `[termynal]` extra (`pip install "mkdocs-typer2[termynal]"`); using the mode without it raises a clear install hint ([#35](https://github.com/syn54x/mkdocs-typer2/pull/35)).
- Termynal `:command:` directive option: render a specific subcommand's `--help` instead of the root, selected by a space-separated path (e.g. `:command: subapp sub-command`); `:subcommands:` recursion applies relative to it. Block-level only.
- Termynal options, each available per block (`:option:`) and globally (`termynal_`-prefixed, e.g. `termynal_width`): `subcommands` (recursion depth: `0` root only, `N` levels, `-1` full tree), `width`, `scheme`, `dark_bg`, `buttons` (`macos`/`windows`), `prompt`, and `type_delay`/`line_delay`/`start_delay` animation timings. Invalid `scheme`/`buttons` values fall back to their defaults.
- `CLI (Termynal)` documentation page demonstrating the new mode.
- Documentation for serving termynal blocks under Zensical: register `termynal.css` / `termynal.js` via `extra_css` / `extra_javascript` (CDN one-liner or self-hosted), since Zensical does not run the `termynal` MkDocs plugin.

## [0.3.1] - 2026-05-27

### Fixed
Expand Down
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ I created this plugin because the original plugin was no longer working for me,
- Easy to configure and use
- `pretty` feature for formatting arguments & options as tables
- `engine` option to select legacy markdown parsing or native Click walking
- `termynal` output mode that renders `--help` as an animated, colored terminal
- Global plugin configuration or per-documentation block configuration

## How It Works
Expand Down Expand Up @@ -157,6 +158,33 @@ engine = "native"

If you share one project between MkDocs and Zensical, keep `mkdocs-typer2` out of `plugins` for the Zensical-focused config (or use separate config files) so the Markdown extension is not applied twice.

#### Termynal assets under Zensical

The `:termynal:` output mode only emits termynal's `data-termynal` markup; the CSS/JS that styles and animates it is shipped separately. Under MkDocs the `termynal` plugin injects them, but **Zensical does not run that plugin**, so the blocks render as unstyled text unless you add the assets yourself.

The simplest way is to point `extra_css` / `extra_javascript` at termynal's assets on a CDN. Pin the version to the `termynal` you installed (`pip show termynal`) so the assets match the markup this extension emits:

**`mkdocs.yml`:**

```yaml
extra_css:
- https://cdn.jsdelivr.net/gh/termynal/termynal.py@0.14.0/termynal/assets/termynal.css
extra_javascript:
- https://cdn.jsdelivr.net/gh/termynal/termynal.py@0.14.0/termynal/assets/termynal.js
```

**`zensical.toml`:**

```toml
[project]
extra_css = ["https://cdn.jsdelivr.net/gh/termynal/termynal.py@0.14.0/termynal/assets/termynal.css"]
extra_javascript = ["https://cdn.jsdelivr.net/gh/termynal/termynal.py@0.14.0/termynal/assets/termynal.js"]
```

To self-host instead, copy `termynal.css` / `termynal.js` from the installed `termynal` package's `assets/` directory into your docs tree and reference them by relative path.

Do **not** inline the CSS/JS into page content (Zensical folds raw `<style>` text into the page title/heading). Use `extra_css` / `extra_javascript` so the assets load in the page head/footer as intended.

## Usage

### Basic Usage
Expand All @@ -178,6 +206,93 @@ In your Markdown files, use the `::: mkdocs-typer2` directive to generate docume
- `:name:` - The name of the CLI. If left blank, your CLI will simply be named `CLI` in your documentation.
- `:pretty:` - Set to `true` to enable pretty formatting for this specific documentation block, overriding the global setting.
- `:engine:` - `legacy` parses Typer markdown (deprecated). `native` walks Click and renders lists or tables based on `pretty`.
- `:termynal:` - Set to `true` to render the CLI's `--help` as an animated, colored [termynal](https://github.com/termynal/termynal.py) terminal instead of Markdown tables. By default only the root command's `--help` is rendered (see `:subcommands:` to include nested commands). Overrides the global `termynal` setting.
- `:command:` - Render a specific subcommand instead of the root. A space-separated path selects nested commands (e.g. `:command: export` renders `<cli> export --help`; `:command: subapp sub-command` goes one level deeper). `:subcommands:` recursion then applies relative to the selected command. Block-level only.
- `:subcommands:` - Recursion depth for termynal output. `0` (default) renders only the selected command's `--help`; `1` adds a block per direct subcommand, `2` adds their subcommands, and so on; `-1` renders every level. Hidden commands are skipped at every level.
- `:width:` - Terminal width (in columns) used when capturing `--help` for termynal output. Defaults to `80`.
- `:scheme:` - Color palette for termynal output. One of `ansi2html`, `dracula`, `mint-terminal`, `osx`, `osx-basic`, `osx-solid-colors`, `solarized`, `xterm`. Invalid values fall back to `xterm` (the default).
- `:dark_bg:` - Set to `false` to use the scheme's light-background variant. Defaults to `true`.
- `:buttons:` - Window chrome style for termynal output. One of `macos` (default) or `windows`. Invalid values fall back to `macos`.
- `:prompt:` - Prompt symbol shown before the `--help` command. Defaults to `$`.
- `:type_delay:` / `:line_delay:` / `:start_delay:` - Termynal animation timings in milliseconds (per character, per line, before start). Left unset, termynal's own defaults apply.

### Termynal Output Mode

Termynal mode introspects the Typer/Click app in-process and emits a faithful,
colored terminal of what `<cmd> --help` prints. Typer apps (which render help
through rich) come out colored; plain Click apps render their monochrome help.
Nothing is executed as a subprocess. By default only the root command is shown;
set `:subcommands:` (or `termynal_subcommands`) to a depth to stack its
subcommands' `--help` below it (`-1` for the full tree).

How it works: the app module is imported and each command's `--help` is rendered
in-process (forcing rich's terminal output so color is preserved). Hidden
commands are skipped, matching what `--help` itself shows. The ANSI output is
converted to inline HTML with [`ansi2html`](https://github.com/pycontribs/ansi2html)
and wrapped in termynal's `data-ty` markup, which `termynal.js` animates. It does
not import termynal's Python renderer — it emits the markup directly, and
`tests/test_termynal_contract.py` guards that markup against drift.

Enable it globally via the MkDocs plugin:

```yaml
plugins:
- mkdocs-typer2:
termynal: true
termynal_subcommands: 0
termynal_width: 80
termynal_scheme: xterm
termynal_dark_bg: true
termynal_buttons: macos
termynal_prompt: "$"
# termynal_type_delay / termynal_line_delay / termynal_start_delay (ms)
# may also be set; unset, termynal's own animation defaults apply.
```

Every block-level option above has a global `termynal_`-prefixed equivalent
(e.g. `:buttons:` ↔ `termynal_buttons`); the block-level value wins. The
`:command:` selector is block-level only.

or per block:

```markdown
::: mkdocs-typer2
:module: my_module.cli
:name: mycli
:termynal: true
:width: 100
```

To document one subcommand per block — with your own headings and prose around
each — select it with `:command:`:

```markdown
## Export

::: mkdocs-typer2
:module: my_module.cli
:name: mycli
:termynal: true
:command: export
```

**Requirements / caveats:**

- Termynal mode needs the optional `termynal` extra:
`pip install "mkdocs-typer2[termynal]"`. Using `:termynal:` without it raises a
clear install hint. ANSI-to-HTML conversion is done with `ansi2html`; the rest
of mkdocs-typer2 has no termynal dependency.
- The rendered blocks rely on termynal's CSS/JS being present on the page, and
how you provide it differs by builder:
- **MkDocs:** enable the
[`termynal` MkDocs plugin](https://github.com/termynal/termynal.py)
(`plugins: [termynal]`); it injects `termynal.css` / `termynal.js`
automatically.
- **Zensical:** the termynal plugin does not run, so add the assets yourself
via `extra_css` / `extra_javascript` — see
[Termynal assets under Zensical](#termynal-assets-under-zensical).

Without the CSS/JS the blocks render as unstyled text.

## Advanced Usage

Expand Down
18 changes: 18 additions & 0 deletions docs/cli-termynal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# CLI (Termynal)

This page renders the CLI's `--help` as an animated, colored
[termynal](https://github.com/termynal/termynal.py) terminal instead of Markdown
tables. By default only the root command is rendered; the `:subcommands:` option
sets how many levels of subcommands to stack below it (`-1` for the full tree).

Enable it per block with `:termynal: true` (or globally via the plugin's
`termynal: true`). Use `:command:` to render a specific subcommand instead of
the root (e.g. `:command: export`, or `:command: subapp sub-command` for a
nested one). The optional `:width:`, `:scheme:`, and `:dark_bg:` options control
the captured terminal width and color palette.

::: mkdocs-typer2
:module: mkdocs_typer2.cli.cli
:name: mkdocs-typer2
:termynal: true
:subcommands: 1
2 changes: 2 additions & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ nav:
- CLI (Legacy): cli.md
- CLI (Pretty Legacy): cli-pretty-legacy.md
- CLI (Pretty Native): cli-pretty-native.md
- CLI (Termynal): cli-termynal.md
- CHANGELOG: changelog.md

markdown_extensions:
Expand All @@ -54,6 +55,7 @@ markdown_extensions:

plugins:
- search
- termynal
- mkdocstrings:
handlers:
python:
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies = [
[project.optional-dependencies]
mkdocs = ["mkdocs>=1.6.1,<2"]
zensical = ["zensical>=0.0.30,<1"]
# Required only for the :termynal: output mode. ansi2html converts the captured
# --help ANSI to HTML; termynal provides the page CSS/JS (<1 guards its markup).
termynal = ["ansi2html>=1.8", "termynal>=0.12,<1"]

[project.scripts]
"mkdocs-typer2" = "mkdocs_typer2.cli.cli:app"
Expand All @@ -39,6 +42,9 @@ dev = [
"markdown-it-py>=3.0.0",
"pydantic>=2.9.2",
"zensical>=0.0.30,<1",
# so the termynal-mode tests run in CI
"ansi2html>=1.8",
"termynal>=0.12,<1",
]

[tool.pytest.ini_options]
Expand Down
119 changes: 116 additions & 3 deletions src/mkdocs_typer2/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,134 @@
tree_to_markdown,
tree_to_markdown_list,
)
from .termynal_render import TermynalOptions, render_termynal_html


def _directive_value(block: str, key: str) -> str | None:
match = re.search(rf":{key}:\s*(\S+)", block)
return match.group(1) if match else None


def _directive_line(block: str, key: str) -> str | None:
"""Like ``_directive_value`` but captures the rest of the line.

Used for values that may contain spaces, such as a ``:command:`` path
(``plot sub``).
"""
match = re.search(rf":{key}:\s*(.+)", block)
return match.group(1).strip() if match else None


def _as_bool(value: str | None, default: bool) -> bool:
if value is None:
return default
lowered = value.lower()
if lowered in ("true", "1", "yes"):
return True
if lowered in ("false", "0", "no"):
return False
return default


def _as_int(value: str | None, default: int | None) -> int | None:
if value is None:
return default
try:
return int(value)
except ValueError:
return default


class TyperExtension(markdown.Extension):
def __init__(
self, *args, pretty: bool | None = None, engine: str = "legacy", **kwargs
self,
*args,
pretty: bool | None = None,
engine: str = "legacy",
termynal: bool = False,
width: int = TermynalOptions.width,
scheme: str = TermynalOptions.scheme,
dark_bg: bool = TermynalOptions.dark_bg,
buttons: str = TermynalOptions.buttons,
prompt: str = TermynalOptions.prompt,
type_delay: int | None = TermynalOptions.type_delay,
line_delay: int | None = TermynalOptions.line_delay,
start_delay: int | None = TermynalOptions.start_delay,
subcommands: int = TermynalOptions.subcommands,
**kwargs,
):
super().__init__(*args, **kwargs)
self.pretty = pretty
self.engine = engine
self.termynal = termynal
# Termynal render options are bundled so they thread through as one
# object instead of a kwarg list duplicated across Extension/Processor.
self.termynal_options = TermynalOptions(
width=width,
scheme=scheme,
dark_bg=dark_bg,
buttons=buttons,
prompt=prompt,
type_delay=type_delay,
line_delay=line_delay,
start_delay=start_delay,
subcommands=subcommands,
)

def extendMarkdown(self, md: markdown.Markdown) -> None:
md.parser.blockprocessors.register(
TyperProcessor(md.parser, pretty=self.pretty, engine=self.engine),
TyperProcessor(
md.parser,
pretty=self.pretty,
engine=self.engine,
termynal=self.termynal,
options=self.termynal_options,
),
"typer",
175,
)


class TyperProcessor(BlockProcessor):
def __init__(
self, *args, pretty: bool | None = None, engine: str = "legacy", **kwargs
self,
*args,
pretty: bool | None = None,
engine: str = "legacy",
termynal: bool = False,
options: TermynalOptions | None = None,
**kwargs,
):
super().__init__(*args, **kwargs)
self.pretty = pretty
self.engine = engine
self.termynal = termynal
self.options = options or TermynalOptions()

def test(self, parent, block):
return block.strip().startswith(":::") and "mkdocs-typer2" in block

def _resolve_termynal_options(self, block: str) -> TermynalOptions:
"""Build per-block options from the globals plus directive overrides."""
base = self.options
return TermynalOptions(
width=_as_int(_directive_value(block, "width"), base.width),
scheme=_directive_value(block, "scheme") or base.scheme,
dark_bg=_as_bool(_directive_value(block, "dark_bg"), base.dark_bg),
buttons=_directive_value(block, "buttons") or base.buttons,
# Capture the rest of the line so a multi-word prompt (e.g. ``my $``)
# is kept whole rather than truncated at the first token.
prompt=_directive_line(block, "prompt") or base.prompt,
type_delay=_as_int(_directive_value(block, "type_delay"), base.type_delay),
line_delay=_as_int(_directive_value(block, "line_delay"), base.line_delay),
start_delay=_as_int(
_directive_value(block, "start_delay"), base.start_delay
),
subcommands=_as_int(
_directive_value(block, "subcommands"), base.subcommands
),
)

def run(self, parent, blocks):
block = blocks.pop(0)

Expand All @@ -54,6 +153,20 @@ def run(self, parent, blocks):
module = module_match.group(1)
name = name_match.group(1) if name_match else ""

use_termynal = _as_bool(_directive_value(block, "termynal"), self.termynal)
if use_termynal:
html = render_termynal_html(
module,
name,
self._resolve_termynal_options(block),
command=_directive_line(block, "command") or "",
)
placeholder = self.parser.md.htmlStash.store(html)
div = etree.SubElement(parent, "div")
div.set("class", "termynal-typer-docs")
div.text = placeholder
return True

# Determine if pretty formatting should be used
# Block-level setting overrides global setting if present
use_pretty = self.pretty # Start with global setting
Expand Down
Loading
Loading