termrender 0.2.1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termrender-0.2.1 → termrender-0.4.0}/.gitignore +3 -0
- {termrender-0.2.1 → termrender-0.4.0}/CHANGELOG.md +44 -0
- termrender-0.4.0/CLAUDE.md +64 -0
- {termrender-0.2.1 → termrender-0.4.0}/PKG-INFO +1 -1
- termrender-0.4.0/src/termrender/CLAUDE.md +59 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/__main__.py +55 -7
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/layout.py +24 -2
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/parser.py +97 -23
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/CLAUDE.md +14 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/borders.py +12 -2
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/mermaid.py +1 -1
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/panel.py +15 -1
- termrender-0.4.0/src/termrender/renderers/table.py +158 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/text.py +17 -6
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/style.py +27 -1
- termrender-0.4.0/tests/test_myst_gaps.py +114 -0
- termrender-0.4.0/tests/test_variable_colons.py +162 -0
- termrender-0.2.1/src/termrender/CLAUDE.md +0 -59
- termrender-0.2.1/src/termrender/renderers/table.py +0 -83
- {termrender-0.2.1 → termrender-0.4.0}/.github/workflows/publish.yml +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/LICENSE +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/README.md +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/design.json +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/pyproject.toml +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/requirements.json +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/__init__.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/blocks.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/emit.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/py.typed +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/code.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/tests/__init__.py +0 -0
- {termrender-0.2.1 → termrender-0.4.0}/tests/test_column_alignment.py +0 -0
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.4.0 (2026-04-05)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **parser**: Variable colon counts, backtick fence directives, and gloam-inspired theming
|
|
9
|
+
([`47fac7f`](https://github.com/CaptainCrouton89/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
|
|
10
|
+
|
|
11
|
+
Parser changes: - Support 3+ colon openers/closers with stack-based matching - Backtick fence
|
|
12
|
+
directive syntax (```{name}) via mistune AST interception - Option line stripping (:key: value)
|
|
13
|
+
into directive attrs
|
|
14
|
+
|
|
15
|
+
CLI changes: - Syntax validation before tmux pane creation (no orphan panes on bad input) - TTY
|
|
16
|
+
auto-detect for color (disabled when piping, forced in tmux subprocess)
|
|
17
|
+
|
|
18
|
+
Theming (gloam-inspired defaults): - Headings: depth-based colored fg + dim tinted bg
|
|
19
|
+
(yellow→green→cyan→blue→magenta) - Inline code: cyan (aqua) - Panel borders: dim gray with yellow
|
|
20
|
+
bold titles - Table borders: blue dim, headers: yellow bold on dim-blue bg - Background color
|
|
21
|
+
support added to style()
|
|
22
|
+
|
|
23
|
+
24 new tests across two test files.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## v0.3.0 (2026-04-05)
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
- Update CLAUDE.md notes for mermaid, tmux, and layout
|
|
31
|
+
([`9e104d5`](https://github.com/CaptainCrouton89/termrender/commit/9e104d5ee7bad9a57902e79586c02b0e8d80c589))
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
- **cli**: Auto-size tmux pane to fit rendered content
|
|
36
|
+
([`91f0414`](https://github.com/CaptainCrouton89/termrender/commit/91f0414d0bf8bfbe4d7167159b928ed9c736db74))
|
|
37
|
+
|
|
38
|
+
- **mermaid**: Pass width and vertical padding to mermaid-ascii
|
|
39
|
+
([`96145c2`](https://github.com/CaptainCrouton89/termrender/commit/96145c2789a52a4d94e9bc5f4adf7f3a88d8501f))
|
|
40
|
+
|
|
41
|
+
- **table**: Auto-wrap cell content when columns overflow
|
|
42
|
+
([`0fae56f`](https://github.com/CaptainCrouton89/termrender/commit/0fae56f8f00260c3263671df9a63a5bea17820bb))
|
|
43
|
+
|
|
44
|
+
When a table exceeds available width, cells now wrap text within their proportionally-shrunk column
|
|
45
|
+
widths instead of overflowing. Layout height calculation updated to account for multi-line cells.
|
|
46
|
+
|
|
47
|
+
|
|
4
48
|
## v0.2.1 (2026-04-05)
|
|
5
49
|
|
|
6
50
|
### Bug Fixes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
termrender renders directive-flavored markdown to ANSI terminal output. LLM agents describe layout with `:::directives` (panels, columns, trees, callouts, etc.) and termrender produces styled terminal output. Public API: `from termrender import render`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install in dev mode
|
|
13
|
+
pip install -e .
|
|
14
|
+
|
|
15
|
+
# Run tests
|
|
16
|
+
pytest tests/
|
|
17
|
+
pytest tests/test_column_alignment.py::TestColumnAlignment::test_showpiece_renders_without_error
|
|
18
|
+
|
|
19
|
+
# Run the CLI
|
|
20
|
+
python -m termrender <file.md>
|
|
21
|
+
echo ':::panel{title="Hi"}\nHello\n:::' | python -m termrender
|
|
22
|
+
|
|
23
|
+
# Build
|
|
24
|
+
python -m build
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
No linter or formatter is configured.
|
|
28
|
+
|
|
29
|
+
## Architecture
|
|
30
|
+
|
|
31
|
+
Three-stage pipeline: **parse → layout → emit**.
|
|
32
|
+
|
|
33
|
+
1. **Parse** (`parser.py`) — Two-pass: regex extracts `:::directives` first, then mistune v3 processes markdown segments. Produces a tree of `Block` dataclasses (`blocks.py`).
|
|
34
|
+
2. **Layout** (`layout.py`) — Two-pass, order is load-bearing: `resolve_width()` top-down, then `resolve_height()` bottom-up. Width must resolve first because height calls `wrap_text(text, width)`.
|
|
35
|
+
3. **Emit** (`emit.py`) — Walks the block tree and dispatches to renderer functions in `renderers/`.
|
|
36
|
+
|
|
37
|
+
Entry points:
|
|
38
|
+
- **Library**: `__init__.py:render()` — parse → layout → emit
|
|
39
|
+
- **CLI**: `__main__.py:main()` — argparse with `--width`, `--no-color`, `--check`, `--cjk`, `--tmux`. Exit codes: 0=ok, 1=input, 2=syntax, 3=terminal.
|
|
40
|
+
|
|
41
|
+
### Renderers (`src/termrender/renderers/`)
|
|
42
|
+
|
|
43
|
+
Two signatures (not type-enforced):
|
|
44
|
+
- **Leaf**: `render(block, color) -> list[str]` — divider, tree
|
|
45
|
+
- **Container**: `render(block, color, render_child) -> list[str]` — panel, quote, code, columns, callout, table, text, mermaid
|
|
46
|
+
|
|
47
|
+
`borders.py` is a shared utility, not a renderer. Its `render_box(content_lines, width, ...)` takes **total** width (including borders), not content width.
|
|
48
|
+
|
|
49
|
+
### Style (`style.py`)
|
|
50
|
+
|
|
51
|
+
`visual_len()` measures display width accounting for ANSI escapes, emoji, CJK, and combining marks. `wrap_text()` uses `len()` internally (known bug: CJK overflow). `_ambiguous_width` is global mutable state with no reset path — set via `set_ambiguous_width()` or `TERMRENDER_CJK` env var.
|
|
52
|
+
|
|
53
|
+
## Conventions
|
|
54
|
+
|
|
55
|
+
- **Commits**: conventional commits (`feat:`, `fix:`, `chore:`, etc.). `feat` → minor, `fix`/`perf` → patch. Auto-released via python-semantic-release on main.
|
|
56
|
+
- **Version**: derived from git tags via hatch-vcs (no version in pyproject.toml).
|
|
57
|
+
- **Python**: 3.10+.
|
|
58
|
+
|
|
59
|
+
## Supplementary CLAUDE.md files
|
|
60
|
+
|
|
61
|
+
- `src/termrender/CLAUDE.md` — parser, layout, mermaid, nesting, and `--check`/`--tmux` implementation gotchas
|
|
62
|
+
- `src/termrender/renderers/CLAUDE.md` — renderer contracts, `render_box` width semantics, EAW edge cases
|
|
63
|
+
|
|
64
|
+
Read these before modifying layout, parsing, or renderer code.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Rich terminal rendering of directive-flavored markdown
|
|
5
5
|
Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
|
|
6
6
|
Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# termrender/src/termrender
|
|
2
|
+
|
|
3
|
+
## Layout: two-pass order is mandatory
|
|
4
|
+
|
|
5
|
+
`layout.py` runs `resolve_width()` then `resolve_height()` — this order is load-bearing. Height resolution calls `wrap_text(text, width)`, so every block must have `.width` set first. Reversing the order causes `block.width = None`, which silently falls back to `width = 1` (layout.py:77), drastically underestimating all heights.
|
|
6
|
+
|
|
7
|
+
## Mermaid: subprocess runs in layout, not in the renderer
|
|
8
|
+
|
|
9
|
+
`layout.py:119–134` runs `mermaid-ascii` and caches the result in `block.attrs["_rendered"]`. The `mermaid` renderer (renderers/mermaid.py) reads this cache key; if it's absent, it runs the subprocess again. A failed layout subprocess (tool missing, timeout) silently stores the raw source diagram in `_rendered`, so the renderer falls back to printing source — no error is raised. Both sites have a 30s timeout.
|
|
10
|
+
|
|
11
|
+
`layout.py` imports `fix_mermaid_encoding` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for this import. The two subprocess call sites differ: layout uses `check=True` (non-zero exit raises `CalledProcessError` → caught → raw source fallback); the renderer omits `check` (non-zero exit silently reads `stdout`, which may be empty or partial). See `renderers/CLAUDE.md` for encoding-fix details.
|
|
12
|
+
|
|
13
|
+
## Directive nesting: depth counter, not stack entries
|
|
14
|
+
|
|
15
|
+
The parser handles two directive syntaxes through different passes: colon directives (`:::name`) are extracted in pass 1 via regex in `_split_directives`, while backtick fence directives (`` ```{name} ``) are resolved in pass 2 via mistune's AST walk in `_convert_ast`. Both paths funnel through `_directive_to_block` for block construction.
|
|
16
|
+
|
|
17
|
+
For colon directives, the parser tracks nesting with a `depth` integer inside the top stack entry, not separate stack entries (parser.py:241–306). A closer `:::` only pops the stack when `depth == 1`; otherwise it decrements depth and treats the closer as body content. Consequence: innermost closers appear verbatim in the body of the parent and are re-parsed on the recursive `parse()` call at line 351.
|
|
18
|
+
|
|
19
|
+
Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError`. Both the render path and `--check` path in `__main__.py` catch `ValueError` and map it to exit code 2 (`EXIT_SYNTAX`).
|
|
20
|
+
|
|
21
|
+
## `--check` validates parse only, not layout
|
|
22
|
+
|
|
23
|
+
`__main__.py` `--check` calls `parse()` directly and exits — it never runs `layout.py`. Layout-time failures (mermaid subprocess missing, column percent overflow, `resolve_width`/`resolve_height` exceptions) pass `--check` cleanly but crash at render time. Use `--check` to catch directive syntax errors, not to guarantee a successful render.
|
|
24
|
+
|
|
25
|
+
## `_ambiguous_width` is global mutable state; `TERMRENDER_CJK` makes it permanent
|
|
26
|
+
|
|
27
|
+
`style.set_ambiguous_width(n)` (style.py:21–23) changes East Asian ambiguous-width measurement for the entire process with no reset function. The CLI `--cjk` flag sets `os.environ["TERMRENDER_CJK"]` rather than calling the function directly, so it persists for the entire process. `__init__.py:30–31` calls `set_ambiguous_width(2)` on every `render()` call when the env var is set — but since there's no reset, a single call in any render context permanently widens ambiguous-width for all subsequent renders in the same process. Affects `visual_len()` and therefore all wrapping and column math.
|
|
28
|
+
|
|
29
|
+
## Column width: explicit widths exceeding available space truncate auto-columns to 1
|
|
30
|
+
|
|
31
|
+
`layout.py:30–63`: explicit column widths (percent or absolute) are allocated first; remaining space is split among auto-width columns with `max(remaining, 0)`. Two columns each claiming 80% of a 100px terminal leaves auto-width columns with width 1 — no error, no proportional scaling.
|
|
32
|
+
|
|
33
|
+
## Height calculations with hidden assumptions
|
|
34
|
+
|
|
35
|
+
- **QUOTE** (layout.py:138): height gets `+1` only when the `author` or `by` attr is set. Using any other key (`attribution`, `source`) silently omits the extra line — the renderer's attribution line is clipped.
|
|
36
|
+
- **TABLE** (layout.py:117): `height = len(rows) + 4` where `rows` includes the header row. The code comment mis-describes this as 5 structural parts; `rows[0]` is the header. Adding a footer or subtitle row needs `+5`, not `+4+1`.
|
|
37
|
+
- **LIST_ITEM** (layout.py:107): text wraps at `max(width - 2, 1)`, hardcoding a 2-column indent. If the renderer changes the indent width, layout height and actual render height diverge silently.
|
|
38
|
+
|
|
39
|
+
## Character offset tracking across wrapped lines
|
|
40
|
+
|
|
41
|
+
`text.py:32–33`: after rendering each wrapped line, the offset advances by the line's length and then skips one character if the next character in the original plain text is a space (the space `wrap_text` consumed). If wrapping preserves trailing spaces, this skip is wrong and subsequent span styling shifts by one character.
|
|
42
|
+
|
|
43
|
+
## `wrap_text` measures in characters, not visual columns
|
|
44
|
+
|
|
45
|
+
`style.py:195–241`: all line-length comparisons inside `wrap_text` use `len()` (character count), not `visual_len()`. A 2-column-wide CJK character is counted as 1 column-unit, so wrapped lines silently overflow their allocated width by one cell per wide character. This affects every block type that calls `wrap_text`.
|
|
46
|
+
|
|
47
|
+
## Unknown directive names silently become PANEL
|
|
48
|
+
|
|
49
|
+
`parser.py:339`: `_DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)` — any unrecognized `:::name` becomes a bordered PANEL block with no error or warning. Typos in directive names (e.g. `:::callOut`) produce visible output that looks correct but lacks the expected behavior (callout type, icon, color).
|
|
50
|
+
|
|
51
|
+
## `--tmux`: exit code reflects pane creation, auto-sizing runs a full render, tempfiles leak
|
|
52
|
+
|
|
53
|
+
`__main__.py:229`: after `tmux split-window` succeeds the parent exits `EXIT_OK` immediately — the pane renders asynchronously. Render errors surface only inside the pane. `--check` is silently dropped with `--tmux` (exit at line 229 precedes the `--check` branch at line 231). Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 214); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`.
|
|
54
|
+
|
|
55
|
+
When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 177–185) to measure content width — mermaid subprocesses fire and `TERMRENDER_CJK` mutations apply here. Any exception silently falls back to `pane_width=80`. The pane is capped to `tmux #{pane_width} - 10`; if the tmux query fails, no cap is applied. Minimum enforced pane width is 20.
|
|
56
|
+
|
|
57
|
+
## `_EMOJI_WIDE_RANGES` must stay sorted by codepoint
|
|
58
|
+
|
|
59
|
+
`_char_width()` (style.py:144) exits the range scan early on `cp < lo`, assuming all ranges are in ascending codepoint order. Adding a new range out of order causes the early exit to skip ranges with higher `lo` values, silently misclassifying those codepoints as 1-wide.
|
|
@@ -158,8 +158,25 @@ def main() -> None:
|
|
|
158
158
|
if args.cjk:
|
|
159
159
|
os.environ["TERMRENDER_CJK"] = "1"
|
|
160
160
|
|
|
161
|
-
# --tmux: render in a new tmux side pane
|
|
161
|
+
# --tmux: render in a new tmux side pane, sized to fit
|
|
162
162
|
if args.tmux:
|
|
163
|
+
# Validate syntax before creating pane — fail fast to caller's terminal
|
|
164
|
+
try:
|
|
165
|
+
from termrender.parser import parse as _parse
|
|
166
|
+
_parse(source)
|
|
167
|
+
except DirectiveError as e:
|
|
168
|
+
_error(
|
|
169
|
+
f"syntax error: {e}",
|
|
170
|
+
fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
|
|
171
|
+
code=EXIT_SYNTAX,
|
|
172
|
+
)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
_error(
|
|
175
|
+
f"nesting error: {e}",
|
|
176
|
+
fix="reduce directive nesting depth (max 50 levels)",
|
|
177
|
+
code=EXIT_SYNTAX,
|
|
178
|
+
)
|
|
179
|
+
|
|
163
180
|
import shlex
|
|
164
181
|
import subprocess
|
|
165
182
|
import tempfile
|
|
@@ -168,7 +185,35 @@ def main() -> None:
|
|
|
168
185
|
_error("not inside a tmux session",
|
|
169
186
|
fix="run inside tmux or omit --tmux")
|
|
170
187
|
|
|
171
|
-
#
|
|
188
|
+
# Determine desired pane width
|
|
189
|
+
if args.width:
|
|
190
|
+
pane_width = args.width
|
|
191
|
+
else:
|
|
192
|
+
# Preview render to measure content width
|
|
193
|
+
from termrender.style import visual_len
|
|
194
|
+
try:
|
|
195
|
+
preview = render(source, width=80, color=False)
|
|
196
|
+
max_w = max(
|
|
197
|
+
(visual_len(line) for line in preview.split('\n') if line),
|
|
198
|
+
default=40,
|
|
199
|
+
)
|
|
200
|
+
pane_width = max(max_w, 40)
|
|
201
|
+
except Exception:
|
|
202
|
+
pane_width = 80
|
|
203
|
+
|
|
204
|
+
# Cap to available tmux space (leave room for the source pane)
|
|
205
|
+
try:
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
["tmux", "display-message", "-p", "#{pane_width}"],
|
|
208
|
+
capture_output=True, text=True, check=True,
|
|
209
|
+
)
|
|
210
|
+
available = int(result.stdout.strip())
|
|
211
|
+
pane_width = min(pane_width, available - 10)
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
pane_width = max(pane_width, 20) # absolute minimum
|
|
215
|
+
|
|
216
|
+
# Save source so the new pane can render it
|
|
172
217
|
with tempfile.NamedTemporaryFile(
|
|
173
218
|
mode="w", suffix=".md", prefix="termrender-", delete=False,
|
|
174
219
|
) as f:
|
|
@@ -181,14 +226,14 @@ def main() -> None:
|
|
|
181
226
|
cmd_parts.append("--no-color")
|
|
182
227
|
if args.cjk:
|
|
183
228
|
cmd_parts.append("--cjk")
|
|
184
|
-
|
|
185
|
-
cmd_parts.extend(["-w", str(args.width)])
|
|
229
|
+
cmd_parts.extend(["-w", str(pane_width)])
|
|
186
230
|
|
|
187
|
-
|
|
231
|
+
# TERMRENDER_COLOR=1 forces color on despite stdout piping to less
|
|
232
|
+
pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
|
|
188
233
|
|
|
189
234
|
try:
|
|
190
235
|
subprocess.run(
|
|
191
|
-
["tmux", "split-window", "-h", pane_cmd],
|
|
236
|
+
["tmux", "split-window", "-h", "-f", "-l", str(pane_width), pane_cmd],
|
|
192
237
|
check=True,
|
|
193
238
|
)
|
|
194
239
|
except FileNotFoundError:
|
|
@@ -222,7 +267,10 @@ def main() -> None:
|
|
|
222
267
|
sys.exit(EXIT_OK)
|
|
223
268
|
|
|
224
269
|
try:
|
|
225
|
-
|
|
270
|
+
use_color = not args.no_color and (
|
|
271
|
+
sys.stdout.isatty() or os.environ.get("TERMRENDER_COLOR") == "1"
|
|
272
|
+
)
|
|
273
|
+
output = render(source, width=args.width, color=use_color)
|
|
226
274
|
except TerminalError as e:
|
|
227
275
|
_error(
|
|
228
276
|
f"terminal error: {e}",
|
|
@@ -113,15 +113,37 @@ def resolve_height(block: Block) -> None:
|
|
|
113
113
|
block.height = len(source.split("\n")) if source else 1
|
|
114
114
|
|
|
115
115
|
elif bt == BlockType.TABLE:
|
|
116
|
+
headers = block.attrs.get("headers", [])
|
|
116
117
|
rows = block.attrs.get("rows", [])
|
|
117
|
-
|
|
118
|
+
n_cols = max(len(headers), max((len(r) for r in rows), default=0))
|
|
119
|
+
if n_cols == 0:
|
|
120
|
+
block.height = 0
|
|
121
|
+
else:
|
|
122
|
+
rh = [_plain_text(headers[i]) if i < len(headers) else "" for i in range(n_cols)]
|
|
123
|
+
rr = [[_plain_text(row[i]) if i < len(row) else "" for i in range(n_cols)] for row in rows]
|
|
124
|
+
col_widths = [
|
|
125
|
+
max(3, visual_len(rh[i]), *(visual_len(r[i]) for r in rr))
|
|
126
|
+
for i in range(n_cols)
|
|
127
|
+
]
|
|
128
|
+
total = sum(col_widths) + n_cols * 2 + (n_cols + 1)
|
|
129
|
+
if total > width:
|
|
130
|
+
avail = max(width - n_cols * 2 - (n_cols + 1), n_cols * 3)
|
|
131
|
+
total_natural = sum(col_widths)
|
|
132
|
+
if total_natural > 0:
|
|
133
|
+
col_widths = [max(3, round(cw / total_natural * avail)) for cw in col_widths]
|
|
134
|
+
header_h = max(len(wrap_text(rh[i], col_widths[i])) for i in range(n_cols))
|
|
135
|
+
data_h = sum(
|
|
136
|
+
max(len(wrap_text(r[i], col_widths[i])) for i in range(n_cols))
|
|
137
|
+
for r in rr
|
|
138
|
+
) if rr else 0
|
|
139
|
+
block.height = header_h + data_h + 3 # top border + header sep + bottom border
|
|
118
140
|
|
|
119
141
|
elif bt == BlockType.MERMAID:
|
|
120
142
|
source = block.attrs.get("source", "") or _plain_text(block.text)
|
|
121
143
|
rendered = source # fallback
|
|
122
144
|
try:
|
|
123
145
|
result = subprocess.run(
|
|
124
|
-
["mermaid-ascii", "-f", "-"],
|
|
146
|
+
["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
|
|
125
147
|
input=source,
|
|
126
148
|
capture_output=True,
|
|
127
149
|
text=True,
|
|
@@ -36,12 +36,12 @@ def _sanitize_text(text: str) -> str:
|
|
|
36
36
|
"""Strip non-SGR ANSI escape sequences from text."""
|
|
37
37
|
return _UNSAFE_ANSI_RE.sub('', text)
|
|
38
38
|
|
|
39
|
-
# Directive opener: :::name or
|
|
39
|
+
# Directive opener: :::name or ::::name etc. (3+ colons)
|
|
40
40
|
_DIRECTIVE_OPEN = re.compile(
|
|
41
|
-
r"
|
|
41
|
+
r"^(:{3,})(\w+)(?:\{([^}]*)\})?\s*$"
|
|
42
42
|
)
|
|
43
|
-
# Directive closer:
|
|
44
|
-
_DIRECTIVE_CLOSE = re.compile(r"
|
|
43
|
+
# Directive closer: 3+ colons on its own line
|
|
44
|
+
_DIRECTIVE_CLOSE = re.compile(r"^(:{3,})\s*$")
|
|
45
45
|
|
|
46
46
|
# Attribute parser: key=value or key="quoted value"
|
|
47
47
|
_ATTR_PAIR = re.compile(
|
|
@@ -61,6 +61,14 @@ _DIRECTIVE_TO_BLOCK: dict[str, BlockType] = {
|
|
|
61
61
|
|
|
62
62
|
_SELF_CLOSING_DIRECTIVES = frozenset({"divider"})
|
|
63
63
|
|
|
64
|
+
# MyST backtick fence directive: ```{name} optional-argument
|
|
65
|
+
_BACKTICK_DIRECTIVE_RE = re.compile(r"^\{(\w[\w-]*)\}(.*)")
|
|
66
|
+
|
|
67
|
+
# MyST option line: :key: value — intentionally requires a value after the key
|
|
68
|
+
# (the \s+(.+) part). Flag-style options like :nosandbox: (no value) won't match
|
|
69
|
+
# and will be treated as body content.
|
|
70
|
+
_OPTION_LINE_RE = re.compile(r"^:(\w[\w-]*):\s+(.+)$")
|
|
71
|
+
|
|
64
72
|
_mistune_md = mistune.create_markdown(renderer="ast", plugins=["table"])
|
|
65
73
|
|
|
66
74
|
|
|
@@ -71,7 +79,7 @@ def _any_self_closing_before(lines: list[str], close_idx: int) -> bool:
|
|
|
71
79
|
if not line:
|
|
72
80
|
continue
|
|
73
81
|
m = _DIRECTIVE_OPEN.match(lines[j])
|
|
74
|
-
if m and m.group(
|
|
82
|
+
if m and m.group(2) in _SELF_CLOSING_DIRECTIVES:
|
|
75
83
|
return True
|
|
76
84
|
return False
|
|
77
85
|
return False
|
|
@@ -117,7 +125,40 @@ def _convert_inline(nodes: list[dict]) -> list[InlineSpan]:
|
|
|
117
125
|
return spans
|
|
118
126
|
|
|
119
127
|
|
|
120
|
-
def
|
|
128
|
+
def _strip_options(body: str) -> tuple[dict[str, str], str]:
|
|
129
|
+
"""Strip MyST option lines from the start of a directive body.
|
|
130
|
+
|
|
131
|
+
Option lines have the form `:key: value` and appear at the start of the body.
|
|
132
|
+
Blank lines between option lines are allowed. Scanning stops at the first
|
|
133
|
+
non-option, non-blank line.
|
|
134
|
+
|
|
135
|
+
Returns (options_dict, remaining_body).
|
|
136
|
+
"""
|
|
137
|
+
if not body or not body.lstrip("\n").startswith(":"):
|
|
138
|
+
return {}, body
|
|
139
|
+
lines = body.split("\n")
|
|
140
|
+
options: dict[str, str] = {}
|
|
141
|
+
last_option_idx = -1
|
|
142
|
+
for i, line in enumerate(lines):
|
|
143
|
+
stripped = line.strip()
|
|
144
|
+
if not stripped:
|
|
145
|
+
# blank lines are OK between options
|
|
146
|
+
continue
|
|
147
|
+
m = _OPTION_LINE_RE.match(stripped)
|
|
148
|
+
if m:
|
|
149
|
+
options[m.group(1)] = m.group(2)
|
|
150
|
+
last_option_idx = i
|
|
151
|
+
else:
|
|
152
|
+
break
|
|
153
|
+
if last_option_idx == -1:
|
|
154
|
+
return {}, body
|
|
155
|
+
remaining = "\n".join(lines[last_option_idx + 1:])
|
|
156
|
+
# Strip leading blank lines from remaining body
|
|
157
|
+
remaining = remaining.lstrip("\n")
|
|
158
|
+
return options, remaining
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
|
|
121
162
|
"""Convert mistune AST nodes into Block tree."""
|
|
122
163
|
blocks: list[Block] = []
|
|
123
164
|
for node in nodes:
|
|
@@ -142,7 +183,24 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
142
183
|
elif ntype == "block_code":
|
|
143
184
|
raw = node.get("raw", "")
|
|
144
185
|
info = node.get("attrs", {}).get("info", "")
|
|
145
|
-
|
|
186
|
+
# MyST backtick fence directive: ```{name} optional-arg
|
|
187
|
+
m_directive = _BACKTICK_DIRECTIVE_RE.match(info) if info else None
|
|
188
|
+
if m_directive:
|
|
189
|
+
dir_name = m_directive.group(1)
|
|
190
|
+
arg_text = m_directive.group(2).strip()
|
|
191
|
+
if dir_name == "mermaid":
|
|
192
|
+
options, body = _strip_options(raw)
|
|
193
|
+
attrs = dict(options)
|
|
194
|
+
if arg_text:
|
|
195
|
+
attrs["argument"] = arg_text
|
|
196
|
+
attrs["source"] = body
|
|
197
|
+
blocks.append(Block(type=BlockType.MERMAID, attrs=attrs))
|
|
198
|
+
else:
|
|
199
|
+
attrs: dict[str, Any] = {}
|
|
200
|
+
if arg_text:
|
|
201
|
+
attrs["argument"] = arg_text
|
|
202
|
+
blocks.append(_directive_to_block(dir_name, attrs, raw, _depth=_depth))
|
|
203
|
+
elif info == "mermaid":
|
|
146
204
|
blocks.append(Block(
|
|
147
205
|
type=BlockType.MERMAID,
|
|
148
206
|
attrs={"source": raw},
|
|
@@ -166,7 +224,7 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
166
224
|
if child["type"] == "block_text":
|
|
167
225
|
item_spans.extend(_convert_inline(child.get("children", [])))
|
|
168
226
|
else:
|
|
169
|
-
sub_blocks.extend(_convert_ast([child]))
|
|
227
|
+
sub_blocks.extend(_convert_ast([child], _depth=_depth))
|
|
170
228
|
items.append(Block(
|
|
171
229
|
type=BlockType.LIST_ITEM,
|
|
172
230
|
text=item_spans,
|
|
@@ -209,23 +267,23 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
209
267
|
blocks.append(Block(type=BlockType.DIVIDER))
|
|
210
268
|
|
|
211
269
|
elif ntype == "block_quote":
|
|
212
|
-
children = _convert_ast(node.get("children", []))
|
|
270
|
+
children = _convert_ast(node.get("children", []), _depth=_depth)
|
|
213
271
|
blocks.append(Block(type=BlockType.QUOTE, children=children))
|
|
214
272
|
|
|
215
273
|
else:
|
|
216
274
|
# Unknown block type - try to extract any content
|
|
217
275
|
if "children" in node:
|
|
218
|
-
blocks.extend(_convert_ast(node["children"]))
|
|
276
|
+
blocks.extend(_convert_ast(node["children"], _depth=_depth))
|
|
219
277
|
|
|
220
278
|
return blocks
|
|
221
279
|
|
|
222
280
|
|
|
223
|
-
def _parse_markdown(source: str) -> list[Block]:
|
|
281
|
+
def _parse_markdown(source: str, _depth: int = 0) -> list[Block]:
|
|
224
282
|
"""Parse a markdown string via mistune and convert to Block list."""
|
|
225
283
|
if not source.strip():
|
|
226
284
|
return []
|
|
227
285
|
ast_nodes = _mistune_md(source)
|
|
228
|
-
return _convert_ast(ast_nodes)
|
|
286
|
+
return _convert_ast(ast_nodes, _depth=_depth)
|
|
229
287
|
|
|
230
288
|
|
|
231
289
|
def _split_directives(source: str) -> list[dict]:
|
|
@@ -247,6 +305,9 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
247
305
|
# Check for directive opener
|
|
248
306
|
m_open = _DIRECTIVE_OPEN.match(line)
|
|
249
307
|
if m_open:
|
|
308
|
+
colons = m_open.group(1)
|
|
309
|
+
name = m_open.group(2)
|
|
310
|
+
attrs_raw = m_open.group(3)
|
|
250
311
|
if not stack:
|
|
251
312
|
# Top-level directive opening — flush accumulated markdown
|
|
252
313
|
if current_md_lines:
|
|
@@ -256,13 +317,14 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
256
317
|
})
|
|
257
318
|
current_md_lines = []
|
|
258
319
|
entry = {
|
|
259
|
-
"name":
|
|
260
|
-
"attrs_raw":
|
|
320
|
+
"name": name,
|
|
321
|
+
"attrs_raw": attrs_raw,
|
|
261
322
|
"body_lines": [],
|
|
262
323
|
"depth": 1,
|
|
324
|
+
"colon_count": len(colons),
|
|
263
325
|
}
|
|
264
326
|
# Self-closing directives (no body content expected)
|
|
265
|
-
if entry["name"] in
|
|
327
|
+
if entry["name"] in _SELF_CLOSING_DIRECTIVES:
|
|
266
328
|
segments.append({
|
|
267
329
|
"type": "directive",
|
|
268
330
|
"name": entry["name"],
|
|
@@ -272,8 +334,9 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
272
334
|
else:
|
|
273
335
|
stack.append(entry)
|
|
274
336
|
else:
|
|
275
|
-
# Nested directive — track depth
|
|
276
|
-
stack[-1]["
|
|
337
|
+
# Nested directive — track depth only if colon count matches
|
|
338
|
+
if len(colons) == stack[-1]["colon_count"]:
|
|
339
|
+
stack[-1]["depth"] += 1
|
|
277
340
|
stack[-1]["body_lines"].append(line)
|
|
278
341
|
i += 1
|
|
279
342
|
continue
|
|
@@ -282,15 +345,20 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
282
345
|
m_close = _DIRECTIVE_CLOSE.match(line)
|
|
283
346
|
if m_close and not stack:
|
|
284
347
|
if not _any_self_closing_before(lines, i):
|
|
348
|
+
close_colons = m_close.group(1)
|
|
285
349
|
raise DirectiveError(
|
|
286
|
-
f"line {i + 1}: stray '
|
|
350
|
+
f"line {i + 1}: stray '{close_colons}' closer with no open directive"
|
|
287
351
|
)
|
|
288
352
|
# Stray closer after a self-closing directive like divider — skip
|
|
289
353
|
i += 1
|
|
290
354
|
continue
|
|
291
355
|
if m_close and stack:
|
|
292
|
-
|
|
293
|
-
|
|
356
|
+
close_colon_count = len(m_close.group(1))
|
|
357
|
+
if close_colon_count != stack[-1]["colon_count"]:
|
|
358
|
+
# Different colon count — treat as body content
|
|
359
|
+
stack[-1]["body_lines"].append(line)
|
|
360
|
+
elif stack[-1]["depth"] > 1:
|
|
361
|
+
# Closing a nested directive with same colon count
|
|
294
362
|
stack[-1]["depth"] -= 1
|
|
295
363
|
stack[-1]["body_lines"].append(line)
|
|
296
364
|
else:
|
|
@@ -322,10 +390,10 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
322
390
|
# If stack is not empty, the source has unclosed directives
|
|
323
391
|
if stack:
|
|
324
392
|
unclosed = stack[-1]
|
|
325
|
-
|
|
393
|
+
colons = ":" * unclosed["colon_count"]
|
|
326
394
|
name = unclosed["name"]
|
|
327
395
|
raise DirectiveError(
|
|
328
|
-
f"unclosed directive '
|
|
396
|
+
f"unclosed directive '{colons}{name}' — missing closing '{colons}'"
|
|
329
397
|
)
|
|
330
398
|
|
|
331
399
|
return segments
|
|
@@ -336,6 +404,12 @@ _MAX_PARSE_DEPTH = 50
|
|
|
336
404
|
|
|
337
405
|
def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0) -> Block:
|
|
338
406
|
"""Convert a parsed directive into a Block."""
|
|
407
|
+
# Strip option lines from body; inline attrs take precedence over options
|
|
408
|
+
options, body = _strip_options(body)
|
|
409
|
+
for key, value in options.items():
|
|
410
|
+
if key not in attrs:
|
|
411
|
+
attrs[key] = value
|
|
412
|
+
|
|
339
413
|
block_type = _DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)
|
|
340
414
|
|
|
341
415
|
# Tree and Code directives: store raw body, don't parse as markdown
|
|
@@ -368,7 +442,7 @@ def parse(source: str, _depth: int = 0) -> Block:
|
|
|
368
442
|
|
|
369
443
|
for seg in segments:
|
|
370
444
|
if seg["type"] == "markdown":
|
|
371
|
-
children.extend(_parse_markdown(seg["content"]))
|
|
445
|
+
children.extend(_parse_markdown(seg["content"], _depth=_depth))
|
|
372
446
|
else:
|
|
373
447
|
children.append(_directive_to_block(
|
|
374
448
|
seg["name"], seg["attrs"], seg["body"], _depth=_depth,
|
|
@@ -38,3 +38,17 @@ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by
|
|
|
38
38
|
|
|
39
39
|
## `panel.py` — callouts delegate through a proxy Block
|
|
40
40
|
`render_callout` patches `title`, `color`, and `type=BlockType.PANEL` into a new `Block` instance and calls `render()` on it. The original block is not mutated. The callout type string (`"info"`, `"warning"`, `"error"`, `"success"`) is only used to look up `_CALLOUT_MAP`; an unknown type falls back to blue `ℹ`.
|
|
41
|
+
|
|
42
|
+
## `mermaid.py` — leaf signature (not container), encoding fix, no returncode check, no truncation
|
|
43
|
+
|
|
44
|
+
**Leaf signature, not container**: `render(block, color)` — no `render_child`. The parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer.
|
|
45
|
+
|
|
46
|
+
**`fix_mermaid_encoding`**: `mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters (e.g. `→` becomes `â\x86\x92`). The fix is `text.encode("latin-1").decode("utf-8")`; on failure it silently returns the corrupted string — callers cannot distinguish fixed vs. corrupt output.
|
|
47
|
+
|
|
48
|
+
**`except Exception` swallows everything**: timeout, missing tool, `MemoryError` — all fall back to `rendered = source` (the raw mermaid source text) silently. Returncode is also never checked; a non-zero exit still reads `result.stdout`, producing a blank-line block if stdout was empty. If both `_rendered` and `source` are absent, empty string is sent to `mermaid-ascii`.
|
|
49
|
+
|
|
50
|
+
**`visual_ljust` pads but never truncates**: every output line is padded to `block.width`. Lines wider than `block.width` overflow without clipping. If `mermaid-ascii` emits a diagram wider than the allocated block, it silently exceeds the layout boundary.
|
|
51
|
+
|
|
52
|
+
**Trailing blank line**: `rendered.split("\n")` on output ending with `\n` (typical subprocess output) produces a trailing empty string, which becomes a `block.width`-wide blank line appended to every mermaid block.
|
|
53
|
+
|
|
54
|
+
Layout pre-renders into `block.attrs["_rendered"]` (see `src/termrender/CLAUDE.md`); this renderer re-runs the subprocess only when that key is absent.
|
|
@@ -11,6 +11,7 @@ def render_box(
|
|
|
11
11
|
color: bool,
|
|
12
12
|
title: str | None = None,
|
|
13
13
|
border_color: str | None = None,
|
|
14
|
+
title_color: str | None = None,
|
|
14
15
|
dim: bool = False,
|
|
15
16
|
) -> list[str]:
|
|
16
17
|
"""Render content lines inside a box-drawing border.
|
|
@@ -21,6 +22,7 @@ def render_box(
|
|
|
21
22
|
color: Whether ANSI styling is enabled.
|
|
22
23
|
title: Optional title to display in the top border.
|
|
23
24
|
border_color: Color name for the border (used by panels).
|
|
25
|
+
title_color: Color for the title text (defaults to border_color).
|
|
24
26
|
dim: Whether to dim the border (used by code blocks).
|
|
25
27
|
"""
|
|
26
28
|
# Calculate border character widths dynamically
|
|
@@ -46,11 +48,19 @@ def render_box(
|
|
|
46
48
|
title_visual = visual_len(title_part)
|
|
47
49
|
remaining = inner_w - title_visual
|
|
48
50
|
fill_count = max(0, remaining // dash_v)
|
|
49
|
-
|
|
51
|
+
if title_color and color:
|
|
52
|
+
# Style title text separately from border chrome
|
|
53
|
+
styled_title = style(title, color=title_color, bold=True)
|
|
54
|
+
border_prefix = style("┌─ ", **style_kw)
|
|
55
|
+
border_suffix = style(" " + "─" * fill_count + "┐", **style_kw)
|
|
56
|
+
top = border_prefix + styled_title + border_suffix
|
|
57
|
+
else:
|
|
58
|
+
top_raw = "┌" + title_part + "─" * fill_count + "┐"
|
|
59
|
+
top = style(top_raw, **style_kw)
|
|
50
60
|
else:
|
|
51
61
|
fill_count = max(0, inner_w // dash_v)
|
|
52
62
|
top_raw = "┌" + "─" * fill_count + "┐"
|
|
53
|
-
|
|
63
|
+
top = style(top_raw, **style_kw)
|
|
54
64
|
top = visual_ljust(top, width)
|
|
55
65
|
|
|
56
66
|
# Bottom border
|
|
@@ -31,7 +31,7 @@ def render(block: Block, color: bool) -> list[str]:
|
|
|
31
31
|
source = block.attrs.get("source", "")
|
|
32
32
|
try:
|
|
33
33
|
result = subprocess.run(
|
|
34
|
-
["mermaid-ascii", "-f", "-"],
|
|
34
|
+
["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
|
|
35
35
|
input=source,
|
|
36
36
|
capture_output=True,
|
|
37
37
|
text=True,
|