termrender 0.7.2__tar.gz → 0.8.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.7.2 → termrender-0.8.0}/CHANGELOG.md +33 -0
- {termrender-0.7.2 → termrender-0.8.0}/PKG-INFO +1 -1
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/CLAUDE.md +3 -1
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/__main__.py +6 -31
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/layout.py +7 -4
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/parser.py +97 -10
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/code.py +15 -3
- termrender-0.8.0/src/termrender/renderers/mermaid.py +98 -0
- termrender-0.8.0/tests/test_mermaid_compat.py +115 -0
- termrender-0.7.2/src/termrender/renderers/mermaid.py +0 -48
- {termrender-0.7.2 → termrender-0.8.0}/.github/workflows/publish.yml +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/.gitignore +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/CLAUDE.md +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/LICENSE +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/README.md +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/design.json +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/pyproject.toml +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/requirements.json +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/__init__.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/blocks.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/emit.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/py.typed +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/table.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/text.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/src/termrender/style.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/__init__.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_charts.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_column_alignment.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_diff.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_inline_badge.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_myst_gaps.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_stat.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_tasklist.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_timeline.py +0 -0
- {termrender-0.7.2 → termrender-0.8.0}/tests/test_variable_colons.py +0 -0
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.8.0 (2026-04-18)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **mermaid**: Preprocess sequence diagrams for mermaid-ascii compatibility
|
|
9
|
+
([`a642576`](https://github.com/CaptainCrouton89/termrender/commit/a642576d41d5dbde372d7de2ab47745296a78e32))
|
|
10
|
+
|
|
11
|
+
mermaid-ascii only parses ->> / -->> arrows, participants, and self-loops; every other common
|
|
12
|
+
sequence-diagram construct made it fail and fall back to raw source. Rewrite Note lines into
|
|
13
|
+
self-loops, map -> / -x / --x / -) / --) / -- > onto the supported arrow pair, drop block keywords
|
|
14
|
+
(loop/alt/activate/ autonumber/end/…), and flatten <br/> to ' / '. Non-sequence diagrams pass
|
|
15
|
+
through unchanged.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## v0.7.3 (2026-04-15)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- **code**: Wrap long code lines to fit layout width
|
|
23
|
+
([`31c6e59`](https://github.com/CaptainCrouton89/termrender/commit/31c6e595a438c4ced8c61fff679b59d4ae55f938))
|
|
24
|
+
|
|
25
|
+
Code blocks previously used raw line count for height and let render_box grow beyond the layout
|
|
26
|
+
allocation. Now wraps source lines to the available content width in both layout and renderer.
|
|
27
|
+
|
|
28
|
+
- **parser**: Add directive trace and file-absolute line numbers to error messages
|
|
29
|
+
([`0f99ea0`](https://github.com/CaptainCrouton89/termrender/commit/0f99ea0310116f8fa06e933cd26126246d7a3b43))
|
|
30
|
+
|
|
31
|
+
Stray-closer and unclosed-directive errors now print the full open/close trace and, when nested
|
|
32
|
+
directives share a colon count, name the specific cause and suggest the fix. Recursive body
|
|
33
|
+
parsing reports file-absolute line numbers via _line_offset threading through parse →
|
|
34
|
+
_split_directives → _directive_to_block.
|
|
35
|
+
|
|
36
|
+
|
|
4
37
|
## v0.7.2 (2026-04-09)
|
|
5
38
|
|
|
6
39
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
|
|
9
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
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
|
|
11
|
+
`layout.py` imports `fix_mermaid_encoding` and `preprocess_mermaid_for_ascii` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for these imports. 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
|
+
`preprocess_mermaid_for_ascii` rewrites sequence diagrams into the subset `mermaid-ascii` parses (it only supports `->>` / `-->>` arrows, `participant`, and self-loops). `Note over|left of|right of X[,Y]: msg` becomes a self-loop `X->>X: 📝 msg`; `->`, `-x`, `--x`, `-)`, `--)`, and bare `-->` are mapped to `->>`/`-->>`; block keywords (`loop`/`alt`/`opt`/`par`/`critical`/`break`/`rect`/`activate`/`deactivate`/`autonumber`/`else`/`and`/`end`) are dropped so the inner arrow lines still render; `<br/>` is flattened to ` / `. Non-sequence diagrams (`flowchart`, `graph`, etc.) pass through unchanged. Semantics are lossy by design — `-x` (fail arrow) renders as a plain arrow, and block scoping is lost — but the flow diagram renders instead of silently degrading to raw source.
|
|
12
14
|
|
|
13
15
|
## Directive nesting: outer must have more colons than inner
|
|
14
16
|
|
|
@@ -298,17 +298,9 @@ def main() -> None:
|
|
|
298
298
|
from termrender.parser import parse as _parse
|
|
299
299
|
_parse(source)
|
|
300
300
|
except DirectiveError as e:
|
|
301
|
-
_error(
|
|
302
|
-
f"syntax error: {e}",
|
|
303
|
-
fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
|
|
304
|
-
code=EXIT_SYNTAX,
|
|
305
|
-
)
|
|
301
|
+
_error(f"syntax error: {e}", code=EXIT_SYNTAX)
|
|
306
302
|
except ValueError as e:
|
|
307
|
-
_error(
|
|
308
|
-
f"nesting error: {e}",
|
|
309
|
-
fix="reduce directive nesting depth (max 50 levels)",
|
|
310
|
-
code=EXIT_SYNTAX,
|
|
311
|
-
)
|
|
303
|
+
_error(f"nesting error: {e}", code=EXIT_SYNTAX)
|
|
312
304
|
|
|
313
305
|
import shlex
|
|
314
306
|
import subprocess
|
|
@@ -447,17 +439,9 @@ def main() -> None:
|
|
|
447
439
|
from termrender.parser import parse
|
|
448
440
|
parse(source)
|
|
449
441
|
except DirectiveError as e:
|
|
450
|
-
_error(
|
|
451
|
-
f"syntax error: {e}",
|
|
452
|
-
fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
|
|
453
|
-
code=EXIT_SYNTAX,
|
|
454
|
-
)
|
|
442
|
+
_error(f"syntax error: {e}", code=EXIT_SYNTAX)
|
|
455
443
|
except ValueError as e:
|
|
456
|
-
_error(
|
|
457
|
-
f"nesting error: {e}",
|
|
458
|
-
fix="reduce directive nesting depth (max 50 levels)",
|
|
459
|
-
code=EXIT_SYNTAX,
|
|
460
|
-
)
|
|
444
|
+
_error(f"nesting error: {e}", code=EXIT_SYNTAX)
|
|
461
445
|
print("ok", file=sys.stderr)
|
|
462
446
|
sys.exit(EXIT_OK)
|
|
463
447
|
|
|
@@ -474,18 +458,9 @@ def main() -> None:
|
|
|
474
458
|
code=EXIT_TERMINAL,
|
|
475
459
|
)
|
|
476
460
|
except DirectiveError as e:
|
|
477
|
-
_error(
|
|
478
|
-
f"syntax error: {e}",
|
|
479
|
-
fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
|
|
480
|
-
hint="run: termrender --check <file> to validate before rendering",
|
|
481
|
-
code=EXIT_SYNTAX,
|
|
482
|
-
)
|
|
461
|
+
_error(f"syntax error: {e}", code=EXIT_SYNTAX)
|
|
483
462
|
except ValueError as e:
|
|
484
|
-
_error(
|
|
485
|
-
f"nesting error: {e}",
|
|
486
|
-
fix="reduce directive nesting depth (max 50 levels)",
|
|
487
|
-
code=EXIT_SYNTAX,
|
|
488
|
-
)
|
|
463
|
+
_error(f"nesting error: {e}", code=EXIT_SYNTAX)
|
|
489
464
|
|
|
490
465
|
sys.stdout.write(output)
|
|
491
466
|
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import subprocess
|
|
6
6
|
|
|
7
7
|
from termrender.blocks import Block, BlockType
|
|
8
|
-
from termrender.renderers.mermaid import fix_mermaid_encoding
|
|
8
|
+
from termrender.renderers.mermaid import fix_mermaid_encoding, preprocess_mermaid_for_ascii
|
|
9
9
|
from termrender.style import wrap_text, visual_len
|
|
10
10
|
|
|
11
11
|
|
|
@@ -92,8 +92,11 @@ def resolve_height(block: Block) -> None:
|
|
|
92
92
|
|
|
93
93
|
elif bt == BlockType.CODE:
|
|
94
94
|
source = block.attrs.get("source") or _plain_text(block.text)
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
raw_lines = source.split("\n") if source else [""]
|
|
96
|
+
border_v = visual_len("│")
|
|
97
|
+
content_w = max(width - 2 * border_v - 2, 1)
|
|
98
|
+
total_lines = sum(len(wrap_text(line, content_w)) for line in raw_lines)
|
|
99
|
+
block.height = total_lines + 2
|
|
97
100
|
|
|
98
101
|
elif bt == BlockType.COLUMNS:
|
|
99
102
|
block.height = max((c.height or 0 for c in block.children), default=0)
|
|
@@ -145,7 +148,7 @@ def resolve_height(block: Block) -> None:
|
|
|
145
148
|
try:
|
|
146
149
|
result = subprocess.run(
|
|
147
150
|
["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
|
|
148
|
-
input=source,
|
|
151
|
+
input=preprocess_mermaid_for_ascii(source),
|
|
149
152
|
capture_output=True,
|
|
150
153
|
text=True,
|
|
151
154
|
check=True,
|
|
@@ -405,21 +405,80 @@ def _parse_markdown(source: str, _depth: int = 0) -> list[Block]:
|
|
|
405
405
|
return _convert_ast(ast_nodes, _depth=_depth)
|
|
406
406
|
|
|
407
407
|
|
|
408
|
-
def
|
|
408
|
+
def _find_nested_directives(body_lines: list[str]) -> list[str]:
|
|
409
|
+
"""Return names of directive openers found in body lines."""
|
|
410
|
+
names: list[str] = []
|
|
411
|
+
for line in body_lines:
|
|
412
|
+
m = _DIRECTIVE_OPEN.match(line)
|
|
413
|
+
if m:
|
|
414
|
+
names.append(m.group(2))
|
|
415
|
+
return names
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _stray_closer_message(
|
|
419
|
+
abs_line: int, close_colons: str, trace: list[str],
|
|
420
|
+
last_closed: dict | None,
|
|
421
|
+
) -> str:
|
|
422
|
+
"""Format a stray-closer error with trace and fix suggestion."""
|
|
423
|
+
msg = f"line {abs_line}: stray '{close_colons}' closer — no directive is open"
|
|
424
|
+
if trace:
|
|
425
|
+
msg += "\n\n directive trace:\n" + "\n".join(trace)
|
|
426
|
+
|
|
427
|
+
if last_closed:
|
|
428
|
+
nested = _find_nested_directives(last_closed["body_lines"])
|
|
429
|
+
if nested:
|
|
430
|
+
outer = last_closed["name"]
|
|
431
|
+
inner = nested[0]
|
|
432
|
+
fix_colons = ":" * (last_closed["colon_count"] + 1)
|
|
433
|
+
msg += (
|
|
434
|
+
f"\n\n Likely cause: :::{outer} at line {last_closed['open_line']} "
|
|
435
|
+
f"contains a nested :::{inner} with the same colon count.\n"
|
|
436
|
+
f" The inner ':::' closer (line {last_closed['close_line']}) "
|
|
437
|
+
f"matched the outer directive instead.\n"
|
|
438
|
+
f" Fix: use {fix_colons}{outer} for the outer directive"
|
|
439
|
+
)
|
|
440
|
+
return msg
|
|
441
|
+
|
|
442
|
+
msg += (
|
|
443
|
+
"\n\n Fix: remove this stray closer, or if nesting is intended,\n"
|
|
444
|
+
" use more colons on outer directives (::::outer wraps :::inner)"
|
|
445
|
+
)
|
|
446
|
+
return msg
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _unclosed_directive_message(
|
|
450
|
+
open_line: int, colons: str, name: str, trace: list[str],
|
|
451
|
+
body_lines: list[str],
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Format an unclosed-directive error with trace and fix suggestion."""
|
|
454
|
+
msg = f"line {open_line}: unclosed '{colons}{name}' — add '{colons}' on a new line to close it"
|
|
455
|
+
if trace:
|
|
456
|
+
msg += "\n\n directive trace:\n" + "\n".join(trace)
|
|
457
|
+
return msg
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _split_directives(source: str, _line_offset: int = 0) -> list[dict]:
|
|
409
461
|
"""Split source into directive and markdown segments.
|
|
410
462
|
|
|
411
463
|
Returns a list of segments, each being either:
|
|
412
464
|
{"type": "markdown", "content": str}
|
|
413
|
-
{"type": "directive", "name": str, "attrs": dict, "body": str
|
|
465
|
+
{"type": "directive", "name": str, "attrs": dict, "body": str,
|
|
466
|
+
"body_start_offset": int}
|
|
467
|
+
|
|
468
|
+
On error, raises DirectiveError with a directive trace showing the
|
|
469
|
+
sequence of opens/closes that led to the error state.
|
|
414
470
|
"""
|
|
415
471
|
lines = source.split("\n")
|
|
416
472
|
segments: list[dict] = []
|
|
417
473
|
current_md_lines: list[str] = []
|
|
418
474
|
stack: list[dict] = [] # stack of open directives
|
|
475
|
+
trace: list[str] = [] # event log for error diagnostics
|
|
476
|
+
last_closed: dict | None = None # most recently closed directive entry
|
|
419
477
|
|
|
420
478
|
i = 0
|
|
421
479
|
while i < len(lines):
|
|
422
480
|
line = lines[i]
|
|
481
|
+
abs_line = _line_offset + i + 1 # 1-indexed file-absolute line number
|
|
423
482
|
|
|
424
483
|
# Check for directive opener
|
|
425
484
|
m_open = _DIRECTIVE_OPEN.match(line)
|
|
@@ -440,6 +499,8 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
440
499
|
"attrs_raw": attrs_raw,
|
|
441
500
|
"body_lines": [],
|
|
442
501
|
"colon_count": len(colons),
|
|
502
|
+
"open_line": abs_line,
|
|
503
|
+
"body_start_offset": _line_offset + i + 1,
|
|
443
504
|
}
|
|
444
505
|
# Self-closing directives (no body content expected)
|
|
445
506
|
if entry["name"] in _SELF_CLOSING_DIRECTIVES:
|
|
@@ -448,9 +509,12 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
448
509
|
"name": entry["name"],
|
|
449
510
|
"attrs": _parse_attrs(entry["attrs_raw"]),
|
|
450
511
|
"body": "",
|
|
512
|
+
"body_start_offset": entry["body_start_offset"],
|
|
451
513
|
})
|
|
514
|
+
trace.append(f" line {abs_line}: {colons}{name} (self-closing)")
|
|
452
515
|
else:
|
|
453
516
|
stack.append(entry)
|
|
517
|
+
trace.append(f" line {abs_line}: {colons}{name} opened")
|
|
454
518
|
else:
|
|
455
519
|
# Nested directive — always treat as body content
|
|
456
520
|
stack[-1]["body_lines"].append(line)
|
|
@@ -462,8 +526,9 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
462
526
|
if m_close and not stack:
|
|
463
527
|
if not _any_self_closing_before(lines, i):
|
|
464
528
|
close_colons = m_close.group(1)
|
|
529
|
+
trace.append(f" line {abs_line}: {close_colons} stray closer (nothing is open)")
|
|
465
530
|
raise DirectiveError(
|
|
466
|
-
|
|
531
|
+
_stray_closer_message(abs_line, close_colons, trace, last_closed)
|
|
467
532
|
)
|
|
468
533
|
# Stray closer after a self-closing directive like divider — skip
|
|
469
534
|
i += 1
|
|
@@ -476,11 +541,30 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
476
541
|
else:
|
|
477
542
|
# Closing the open directive
|
|
478
543
|
entry = stack.pop()
|
|
544
|
+
closed_colons = ":" * entry["colon_count"]
|
|
545
|
+
nested = _find_nested_directives(entry["body_lines"])
|
|
546
|
+
nested_note = ""
|
|
547
|
+
if nested:
|
|
548
|
+
names = ", ".join(f":::{n}" for n in nested[:3])
|
|
549
|
+
nested_note = f" — body has nested {names}"
|
|
550
|
+
trace.append(
|
|
551
|
+
f" line {abs_line}: {closed_colons} "
|
|
552
|
+
f"closed {entry['name']} (opened line {entry['open_line']})"
|
|
553
|
+
f"{nested_note}"
|
|
554
|
+
)
|
|
555
|
+
last_closed = {
|
|
556
|
+
"name": entry["name"],
|
|
557
|
+
"colon_count": entry["colon_count"],
|
|
558
|
+
"open_line": entry["open_line"],
|
|
559
|
+
"close_line": abs_line,
|
|
560
|
+
"body_lines": entry["body_lines"],
|
|
561
|
+
}
|
|
479
562
|
segments.append({
|
|
480
563
|
"type": "directive",
|
|
481
564
|
"name": entry["name"],
|
|
482
565
|
"attrs": _parse_attrs(entry["attrs_raw"]),
|
|
483
566
|
"body": "\n".join(entry["body_lines"]),
|
|
567
|
+
"body_start_offset": entry["body_start_offset"],
|
|
484
568
|
})
|
|
485
569
|
i += 1
|
|
486
570
|
continue
|
|
@@ -504,8 +588,10 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
504
588
|
unclosed = stack[-1]
|
|
505
589
|
colons = ":" * unclosed["colon_count"]
|
|
506
590
|
name = unclosed["name"]
|
|
591
|
+
open_line = unclosed["open_line"]
|
|
592
|
+
trace.append(f" line {open_line}: {colons}{name} ← still open at end of input")
|
|
507
593
|
raise DirectiveError(
|
|
508
|
-
|
|
594
|
+
_unclosed_directive_message(open_line, colons, name, trace, unclosed["body_lines"])
|
|
509
595
|
)
|
|
510
596
|
|
|
511
597
|
return segments
|
|
@@ -514,7 +600,7 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
514
600
|
_MAX_PARSE_DEPTH = 50
|
|
515
601
|
|
|
516
602
|
|
|
517
|
-
def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0) -> Block:
|
|
603
|
+
def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0, _line_offset: int = 0) -> Block:
|
|
518
604
|
"""Convert a parsed directive into a Block."""
|
|
519
605
|
# Strip option lines from body; inline attrs take precedence over options
|
|
520
606
|
options, body = _strip_options(body)
|
|
@@ -545,12 +631,12 @@ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int
|
|
|
545
631
|
|
|
546
632
|
# Stat: optional caption body parsed as markdown
|
|
547
633
|
if block_type == BlockType.STAT:
|
|
548
|
-
body_doc = parse(body, _depth=_depth + 1) if body.strip() else Block(type=BlockType.DOCUMENT)
|
|
634
|
+
body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset) if body.strip() else Block(type=BlockType.DOCUMENT)
|
|
549
635
|
return Block(type=block_type, children=body_doc.children, attrs=attrs)
|
|
550
636
|
|
|
551
637
|
# Tasklist alias: parse body, find the inner list, force tasklist styling
|
|
552
638
|
if name == "tasklist":
|
|
553
|
-
body_doc = parse(body, _depth=_depth + 1)
|
|
639
|
+
body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset)
|
|
554
640
|
for child in body_doc.children:
|
|
555
641
|
if child.type == BlockType.LIST:
|
|
556
642
|
child.attrs["tasklist"] = True
|
|
@@ -563,7 +649,7 @@ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int
|
|
|
563
649
|
return Block(type=BlockType.LIST, attrs={"tasklist": True, **attrs})
|
|
564
650
|
|
|
565
651
|
# Recursively parse the body through the full two-pass pipeline
|
|
566
|
-
body_doc = parse(body, _depth=_depth + 1)
|
|
652
|
+
body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset)
|
|
567
653
|
return Block(
|
|
568
654
|
type=block_type,
|
|
569
655
|
children=body_doc.children,
|
|
@@ -641,14 +727,14 @@ def _apply_tasklist_markers(block: Block) -> Block:
|
|
|
641
727
|
return block
|
|
642
728
|
|
|
643
729
|
|
|
644
|
-
def parse(source: str, _depth: int = 0) -> Block:
|
|
730
|
+
def parse(source: str, _depth: int = 0, _line_offset: int = 0) -> Block:
|
|
645
731
|
"""Parse markdown+directive source into a Block tree.
|
|
646
732
|
|
|
647
733
|
Returns a Block with type=DOCUMENT as root.
|
|
648
734
|
"""
|
|
649
735
|
if _depth > _MAX_PARSE_DEPTH:
|
|
650
736
|
raise ValueError(f"Maximum directive nesting depth ({_MAX_PARSE_DEPTH}) exceeded")
|
|
651
|
-
segments = _split_directives(source)
|
|
737
|
+
segments = _split_directives(source, _line_offset=_line_offset)
|
|
652
738
|
children: list[Block] = []
|
|
653
739
|
|
|
654
740
|
for seg in segments:
|
|
@@ -657,6 +743,7 @@ def parse(source: str, _depth: int = 0) -> Block:
|
|
|
657
743
|
else:
|
|
658
744
|
children.append(_directive_to_block(
|
|
659
745
|
seg["name"], seg["attrs"], seg["body"], _depth=_depth,
|
|
746
|
+
_line_offset=seg.get("body_start_offset", 0),
|
|
660
747
|
))
|
|
661
748
|
|
|
662
749
|
# Walk the tree to auto-promote any markdown list with [ ]/[x] markers
|
|
@@ -10,6 +10,7 @@ from pygments.lexers import TextLexer, get_lexer_by_name
|
|
|
10
10
|
|
|
11
11
|
from termrender.blocks import Block
|
|
12
12
|
from termrender.renderers.borders import render_box
|
|
13
|
+
from termrender.style import visual_len, wrap_text
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def render(
|
|
@@ -19,18 +20,29 @@ def render(
|
|
|
19
20
|
source = block.attrs.get("source", "")
|
|
20
21
|
lang = block.attrs.get("lang")
|
|
21
22
|
|
|
23
|
+
# Wrap raw source lines to fit within the box before highlighting,
|
|
24
|
+
# so render_box doesn't need to grow beyond the layout allocation.
|
|
25
|
+
border_v = visual_len("│")
|
|
26
|
+
content_w = max((block.width or 1) - 2 * border_v - 2, 1)
|
|
27
|
+
raw_lines = source.split("\n") if source else [""]
|
|
28
|
+
wrapped_lines = []
|
|
29
|
+
for line in raw_lines:
|
|
30
|
+
wrapped_lines.extend(wrap_text(line, content_w))
|
|
31
|
+
|
|
32
|
+
wrapped_source = "\n".join(wrapped_lines)
|
|
33
|
+
|
|
22
34
|
# Syntax highlight (or plain text)
|
|
23
|
-
if color and
|
|
35
|
+
if color and wrapped_source:
|
|
24
36
|
try:
|
|
25
37
|
lexer = get_lexer_by_name(lang) if lang else TextLexer()
|
|
26
38
|
except Exception:
|
|
27
39
|
lexer = TextLexer()
|
|
28
|
-
highlighted = highlight(
|
|
40
|
+
highlighted = highlight(wrapped_source, lexer, TerminalFormatter())
|
|
29
41
|
# Pygments adds a trailing newline — strip it
|
|
30
42
|
highlighted = highlighted.rstrip("\n")
|
|
31
43
|
code_lines = highlighted.split("\n")
|
|
32
44
|
else:
|
|
33
|
-
code_lines =
|
|
45
|
+
code_lines = wrapped_lines
|
|
34
46
|
|
|
35
47
|
return render_box(
|
|
36
48
|
code_lines,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Mermaid diagram renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from termrender.blocks import Block
|
|
9
|
+
from termrender.style import visual_ljust
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fix_mermaid_encoding(text: str) -> str:
|
|
13
|
+
"""Undo mermaid-ascii's double-encoding of UTF-8 characters.
|
|
14
|
+
|
|
15
|
+
mermaid-ascii misinterprets UTF-8 input bytes as Latin-1 and re-encodes
|
|
16
|
+
to UTF-8, corrupting multi-byte characters (e.g. → becomes â\\x86\\x92).
|
|
17
|
+
Reversing the process: encode back to Latin-1 to recover the original
|
|
18
|
+
UTF-8 bytes, then decode as UTF-8.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
return text.encode("latin-1").decode("utf-8")
|
|
22
|
+
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
23
|
+
return text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_NOTE_RE = re.compile(
|
|
27
|
+
r"^(\s*)[Nn]ote\s+(?:over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
|
|
28
|
+
)
|
|
29
|
+
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
|
30
|
+
_UNSUPPORTED_BLOCK_RE = re.compile(
|
|
31
|
+
r"^\s*(?:loop|alt|else|opt|par|and|critical|option|break|rect|"
|
|
32
|
+
r"activate|deactivate|autonumber|end)\b.*$",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def preprocess_mermaid_for_ascii(source: str) -> str:
|
|
38
|
+
"""Rewrite mermaid sequence diagrams into the subset mermaid-ascii supports.
|
|
39
|
+
|
|
40
|
+
mermaid-ascii only understands ``->>`` and ``-->>`` arrows plus ``participant``
|
|
41
|
+
declarations. This helper converts ``Note`` lines into self-loops, maps the
|
|
42
|
+
other arrow variants (``->``, ``-x``, ``--x``, ``-)``, ``--)``, ``-->``) to
|
|
43
|
+
the supported pair, drops block keywords (``loop``, ``alt``, ``activate``…),
|
|
44
|
+
and flattens ``<br/>`` tags. Non-sequence diagrams are returned unchanged.
|
|
45
|
+
"""
|
|
46
|
+
lines = source.splitlines()
|
|
47
|
+
first = next((l.strip() for l in lines if l.strip()), "")
|
|
48
|
+
if not first.lower().startswith("sequencediagram"):
|
|
49
|
+
return source
|
|
50
|
+
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
for line in lines:
|
|
53
|
+
m = _NOTE_RE.match(line)
|
|
54
|
+
if m:
|
|
55
|
+
indent, parts, msg = m.group(1), m.group(2), m.group(3)
|
|
56
|
+
first_p = parts.split(",")[0].strip()
|
|
57
|
+
msg = _BR_RE.sub(" / ", msg)
|
|
58
|
+
out.append(f"{indent}{first_p}->>{first_p}: 📝 {msg}")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if _UNSUPPORTED_BLOCK_RE.match(line):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
line = _BR_RE.sub(" / ", line)
|
|
65
|
+
line = re.sub(r"--x(?=\s|\w|\()", "-->>", line)
|
|
66
|
+
line = re.sub(r"-x(?=\s|\w|\()", "->>", line)
|
|
67
|
+
line = re.sub(r"--\)(?=\s|\w|\()", "-->>", line)
|
|
68
|
+
line = re.sub(r"-\)(?=\s|\w|\()", "->>", line)
|
|
69
|
+
line = re.sub(r"-->(?!>)", "-->>", line)
|
|
70
|
+
line = re.sub(r"(?<!-)->(?!>)", "->>", line)
|
|
71
|
+
out.append(line)
|
|
72
|
+
return "\n".join(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
76
|
+
"""Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
|
|
77
|
+
w = block.width
|
|
78
|
+
rendered = block.attrs.get("_rendered")
|
|
79
|
+
|
|
80
|
+
if rendered is None:
|
|
81
|
+
source = block.attrs.get("source", "")
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
|
|
85
|
+
input=preprocess_mermaid_for_ascii(source),
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=30,
|
|
89
|
+
)
|
|
90
|
+
rendered = fix_mermaid_encoding(result.stdout)
|
|
91
|
+
except Exception:
|
|
92
|
+
rendered = source
|
|
93
|
+
|
|
94
|
+
lines: list[str] = []
|
|
95
|
+
for raw_line in rendered.split("\n"):
|
|
96
|
+
lines.append(visual_ljust(raw_line, w))
|
|
97
|
+
|
|
98
|
+
return lines
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from termrender.renderers.mermaid import preprocess_mermaid_for_ascii
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestMermaidPreprocessor(unittest.TestCase):
|
|
7
|
+
|
|
8
|
+
def test_non_sequence_diagrams_pass_through(self):
|
|
9
|
+
src = "flowchart TD\n A-->B\n B-->C"
|
|
10
|
+
self.assertEqual(preprocess_mermaid_for_ascii(src), src)
|
|
11
|
+
|
|
12
|
+
def test_note_over_becomes_self_loop(self):
|
|
13
|
+
src = "sequenceDiagram\n participant A\n participant B\n Note over A: hello"
|
|
14
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
15
|
+
self.assertIn("A->>A: 📝 hello", out)
|
|
16
|
+
self.assertNotIn("Note over", out)
|
|
17
|
+
|
|
18
|
+
def test_note_over_multi_participant_picks_first(self):
|
|
19
|
+
src = "sequenceDiagram\n participant A\n participant B\n Note over A,B: shared"
|
|
20
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
21
|
+
self.assertIn("A->>A: 📝 shared", out)
|
|
22
|
+
|
|
23
|
+
def test_note_left_and_right_of(self):
|
|
24
|
+
src = (
|
|
25
|
+
"sequenceDiagram\n"
|
|
26
|
+
" participant X\n"
|
|
27
|
+
" Note left of X: L\n"
|
|
28
|
+
" Note right of X: R"
|
|
29
|
+
)
|
|
30
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
31
|
+
self.assertIn("X->>X: 📝 L", out)
|
|
32
|
+
self.assertIn("X->>X: 📝 R", out)
|
|
33
|
+
|
|
34
|
+
def test_br_tags_flattened_in_note(self):
|
|
35
|
+
src = "sequenceDiagram\n participant A\n Note over A: line1<br/>line2"
|
|
36
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
37
|
+
self.assertIn("A->>A: 📝 line1 / line2", out)
|
|
38
|
+
|
|
39
|
+
def test_br_tags_flattened_in_arrow_message(self):
|
|
40
|
+
src = "sequenceDiagram\n participant A\n participant B\n A->>B: a<br/>b<br />c"
|
|
41
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
42
|
+
self.assertIn("A->>B: a / b / c", out)
|
|
43
|
+
|
|
44
|
+
def test_arrow_variants_rewritten(self):
|
|
45
|
+
src = (
|
|
46
|
+
"sequenceDiagram\n"
|
|
47
|
+
" participant A\n"
|
|
48
|
+
" participant B\n"
|
|
49
|
+
" A-xB: fail\n"
|
|
50
|
+
" A--xB: dashed fail\n"
|
|
51
|
+
" A-)B: async\n"
|
|
52
|
+
" A--)B: dashed async\n"
|
|
53
|
+
" A->B: single\n"
|
|
54
|
+
" A-->B: dashed single\n"
|
|
55
|
+
)
|
|
56
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
57
|
+
self.assertNotIn("-x", out)
|
|
58
|
+
self.assertNotIn("-)", out)
|
|
59
|
+
# Each single-dash variant should be solid ->>
|
|
60
|
+
self.assertIn("A->>B: fail", out)
|
|
61
|
+
self.assertIn("A->>B: async", out)
|
|
62
|
+
self.assertIn("A->>B: single", out)
|
|
63
|
+
# Each double-dash variant should be dashed -->>
|
|
64
|
+
self.assertIn("A-->>B: dashed fail", out)
|
|
65
|
+
self.assertIn("A-->>B: dashed async", out)
|
|
66
|
+
self.assertIn("A-->>B: dashed single", out)
|
|
67
|
+
|
|
68
|
+
def test_existing_double_arrow_preserved(self):
|
|
69
|
+
src = "sequenceDiagram\n participant A\n participant B\n A->>B: x\n A-->>B: y"
|
|
70
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
71
|
+
self.assertIn("A->>B: x", out)
|
|
72
|
+
self.assertIn("A-->>B: y", out)
|
|
73
|
+
# Should not have inflated to ->>>
|
|
74
|
+
self.assertNotIn("->>>", out)
|
|
75
|
+
self.assertNotIn("-->>>", out)
|
|
76
|
+
|
|
77
|
+
def test_block_keywords_dropped(self):
|
|
78
|
+
src = (
|
|
79
|
+
"sequenceDiagram\n"
|
|
80
|
+
" participant A\n"
|
|
81
|
+
" participant B\n"
|
|
82
|
+
" activate A\n"
|
|
83
|
+
" loop forever\n"
|
|
84
|
+
" A->>B: x\n"
|
|
85
|
+
" end\n"
|
|
86
|
+
" deactivate A\n"
|
|
87
|
+
" autonumber\n"
|
|
88
|
+
" alt happy\n"
|
|
89
|
+
" A->>B: y\n"
|
|
90
|
+
" else sad\n"
|
|
91
|
+
" A->>B: z\n"
|
|
92
|
+
" end\n"
|
|
93
|
+
)
|
|
94
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
95
|
+
for dropped in ("activate", "deactivate", "loop", "end", "alt ", "else ", "autonumber"):
|
|
96
|
+
self.assertNotIn(dropped, out.lower())
|
|
97
|
+
# But arrow lines survive
|
|
98
|
+
self.assertIn("A->>B: x", out)
|
|
99
|
+
self.assertIn("A->>B: y", out)
|
|
100
|
+
self.assertIn("A->>B: z", out)
|
|
101
|
+
|
|
102
|
+
def test_participant_aliases_with_parens_preserved(self):
|
|
103
|
+
src = (
|
|
104
|
+
"sequenceDiagram\n"
|
|
105
|
+
" participant C1 as Core (PID 92348)\n"
|
|
106
|
+
" participant C2 as Core (PID 93684)\n"
|
|
107
|
+
" C1->>C2: x"
|
|
108
|
+
)
|
|
109
|
+
out = preprocess_mermaid_for_ascii(src)
|
|
110
|
+
self.assertIn("participant C1 as Core (PID 92348)", out)
|
|
111
|
+
self.assertIn("participant C2 as Core (PID 93684)", out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"""Mermaid diagram renderer for termrender."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
|
|
7
|
-
from termrender.blocks import Block
|
|
8
|
-
from termrender.style import visual_ljust
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def fix_mermaid_encoding(text: str) -> str:
|
|
12
|
-
"""Undo mermaid-ascii's double-encoding of UTF-8 characters.
|
|
13
|
-
|
|
14
|
-
mermaid-ascii misinterprets UTF-8 input bytes as Latin-1 and re-encodes
|
|
15
|
-
to UTF-8, corrupting multi-byte characters (e.g. → becomes â\\x86\\x92).
|
|
16
|
-
Reversing the process: encode back to Latin-1 to recover the original
|
|
17
|
-
UTF-8 bytes, then decode as UTF-8.
|
|
18
|
-
"""
|
|
19
|
-
try:
|
|
20
|
-
return text.encode("latin-1").decode("utf-8")
|
|
21
|
-
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
22
|
-
return text
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def render(block: Block, color: bool) -> list[str]:
|
|
26
|
-
"""Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
|
|
27
|
-
w = block.width
|
|
28
|
-
rendered = block.attrs.get("_rendered")
|
|
29
|
-
|
|
30
|
-
if rendered is None:
|
|
31
|
-
source = block.attrs.get("source", "")
|
|
32
|
-
try:
|
|
33
|
-
result = subprocess.run(
|
|
34
|
-
["mermaid-ascii", "-f", "-", "-w", str(block.width or 80), "-y", "1"],
|
|
35
|
-
input=source,
|
|
36
|
-
capture_output=True,
|
|
37
|
-
text=True,
|
|
38
|
-
timeout=30,
|
|
39
|
-
)
|
|
40
|
-
rendered = fix_mermaid_encoding(result.stdout)
|
|
41
|
-
except Exception:
|
|
42
|
-
rendered = source
|
|
43
|
-
|
|
44
|
-
lines: list[str] = []
|
|
45
|
-
for raw_line in rendered.split("\n"):
|
|
46
|
-
lines.append(visual_ljust(raw_line, w))
|
|
47
|
-
|
|
48
|
-
return lines
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|