Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ 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; the root command renders first, followed by one block per non-hidden direct subcommand. 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 options, each available per block (`:option:`) and globally (`termynal_`-prefixed, e.g. `termynal_width`): `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.

## [0.3.1] - 2026-05-27

### Fixed
Expand Down
62 changes: 62 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 @@ -178,6 +179,67 @@ 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. The root command is rendered first, followed by one block per direct subcommand. Overrides the global `termynal` setting.
- `: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.

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_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.

or per block:

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

**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. Enable
the [`termynal` MkDocs plugin](https://github.com/termynal/termynal.py) (or
otherwise include `termynal.css` / `termynal.js`), or the blocks will not
animate or be styled.

## Advanced Usage

Expand Down
15 changes: 15 additions & 0 deletions docs/cli-termynal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 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. The root command is rendered first, followed by one block per direct
subcommand.

Enable it per block with `:termynal: true` (or globally via the plugin's
`termynal: true`). 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
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
99 changes: 96 additions & 3 deletions src/mkdocs_typer2/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,117 @@
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 _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 = 80,
scheme: str = "xterm",
dark_bg: bool = True,
buttons: str = "macos",
prompt: str = "$",
type_delay: int | None = None,
line_delay: int | None = None,
start_delay: int | None = None,
**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,
)

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,
prompt=_directive_value(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
),
)

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

Expand All @@ -54,6 +136,17 @@ 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)
)
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
45 changes: 45 additions & 0 deletions src/mkdocs_typer2/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,58 @@ class MkdocsTyper(BasePlugin):
"engine",
config_options.Type(str, default="legacy"),
),
(
"termynal",
config_options.Type(bool, default=False),
),
(
"termynal_width",
config_options.Type(int, default=80),
),
(
"termynal_scheme",
config_options.Type(str, default="xterm"),
),
(
"termynal_dark_bg",
config_options.Type(bool, default=True),
),
(
"termynal_buttons",
config_options.Type(str, default="macos"),
),
(
"termynal_prompt",
config_options.Type(str, default="$"),
),
(
"termynal_type_delay",
config_options.Optional(config_options.Type(int)),
),
(
"termynal_line_delay",
config_options.Optional(config_options.Type(int)),
),
(
"termynal_start_delay",
config_options.Optional(config_options.Type(int)),
),
)

def on_config(self, config, **kwargs) -> dict:
config["markdown_extensions"].append(
makeExtension(
pretty=self.config.get("pretty", False),
engine=self.config.get("engine", "legacy"),
termynal=self.config.get("termynal", False),
width=self.config.get("termynal_width", 80),
scheme=self.config.get("termynal_scheme", "xterm"),
dark_bg=self.config.get("termynal_dark_bg", True),
buttons=self.config.get("termynal_buttons", "macos"),
prompt=self.config.get("termynal_prompt", "$"),
type_delay=self.config.get("termynal_type_delay"),
line_delay=self.config.get("termynal_line_delay"),
start_delay=self.config.get("termynal_start_delay"),
)
)
return config
Expand Down
21 changes: 13 additions & 8 deletions src/mkdocs_typer2/pretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,24 @@ class CommandNode(BaseModel):
commands: List[CommandEntry] = Field(default_factory=list)


def build_tree_from_click_app(module: str, name: str) -> CommandNode:
def resolve_click_command(module: str, name: str) -> click.core.Command:
"""Import ``module`` and resolve its Typer/Click app to a Click command.

Uses the attribute named ``name`` when given, otherwise falls back to a
module-level ``app``. Shared by the native engine and termynal output mode.
"""
module_ref = importlib.import_module(module)
app = None
display_name = None
if name:
display_name = name
app = getattr(module_ref, name, None)
app = getattr(module_ref, name, None) if name else None
if app is None:
app = getattr(module_ref, "app", None)
if app is None:
Comment on lines +46 to 49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when explicit name cannot be resolved.

Line 46–49 currently falls back to module-level app even when name was explicitly provided. That masks typos/misconfiguration and can render docs for the wrong command. Only use fallback when name is empty; otherwise raise.

Suggested fix
 def resolve_click_command(module: str, name: str) -> click.core.Command:
@@
-    app = getattr(module_ref, name, None) if name else None
-    if app is None:
-        app = getattr(module_ref, "app", None)
+    if name:
+        app = getattr(module_ref, name, None)
+        if app is None:
+            raise ValueError(
+                f"Unable to resolve Typer app '{name}' from module '{module}'."
+            )
+    else:
+        app = getattr(module_ref, "app", None)
     if app is None:
         raise ValueError(f"Unable to resolve Typer app from module '{module}'.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app = getattr(module_ref, name, None) if name else None
if app is None:
app = getattr(module_ref, "app", None)
if app is None:
if name:
app = getattr(module_ref, name, None)
if app is None:
raise ValueError(
f"Unable to resolve Typer app '{name}' from module '{module}'."
)
else:
app = getattr(module_ref, "app", None)
if app is None:
raise ValueError(f"Unable to resolve Typer app from module '{module}'.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mkdocs_typer2/pretty.py` around lines 46 - 49, The code in the section
starting with the first getattr call for `name` currently falls back to
module-level `app` even when an explicit `name` was provided by the user. This
masks configuration errors. Modify the logic to only use the module-level `app`
fallback when `name` is empty or None; if `name` was explicitly provided but the
attribute cannot be resolved, raise an error instead of silently falling back.

raise ValueError(f"Unable to resolve Typer app from module '{module}'.")
command = _resolve_click_command(app)
return _build_tree_from_click_command(command, display_name=display_name)
return _resolve_click_command(app)


def build_tree_from_click_app(module: str, name: str) -> CommandNode:
command = resolve_click_command(module, name)
return _build_tree_from_click_command(command, display_name=name or None)


def _is_click_group(command: object) -> bool:
Expand Down
Loading