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.
Files changed (47) hide show
  1. {termrender-0.7.2 → termrender-0.8.0}/CHANGELOG.md +33 -0
  2. {termrender-0.7.2 → termrender-0.8.0}/PKG-INFO +1 -1
  3. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/CLAUDE.md +3 -1
  4. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/__main__.py +6 -31
  5. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/layout.py +7 -4
  6. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/parser.py +97 -10
  7. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/code.py +15 -3
  8. termrender-0.8.0/src/termrender/renderers/mermaid.py +98 -0
  9. termrender-0.8.0/tests/test_mermaid_compat.py +115 -0
  10. termrender-0.7.2/src/termrender/renderers/mermaid.py +0 -48
  11. {termrender-0.7.2 → termrender-0.8.0}/.github/workflows/publish.yml +0 -0
  12. {termrender-0.7.2 → termrender-0.8.0}/.gitignore +0 -0
  13. {termrender-0.7.2 → termrender-0.8.0}/CLAUDE.md +0 -0
  14. {termrender-0.7.2 → termrender-0.8.0}/LICENSE +0 -0
  15. {termrender-0.7.2 → termrender-0.8.0}/README.md +0 -0
  16. {termrender-0.7.2 → termrender-0.8.0}/design.json +0 -0
  17. {termrender-0.7.2 → termrender-0.8.0}/pyproject.toml +0 -0
  18. {termrender-0.7.2 → termrender-0.8.0}/requirements.json +0 -0
  19. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/__init__.py +0 -0
  20. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/blocks.py +0 -0
  21. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/emit.py +0 -0
  22. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/py.typed +0 -0
  23. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/CLAUDE.md +0 -0
  24. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/__init__.py +0 -0
  25. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/borders.py +0 -0
  26. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/charts.py +0 -0
  27. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/columns.py +0 -0
  28. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/diff.py +0 -0
  29. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/divider.py +0 -0
  30. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/panel.py +0 -0
  31. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/quote.py +0 -0
  32. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/stat.py +0 -0
  33. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/table.py +0 -0
  34. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/text.py +0 -0
  35. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/timeline.py +0 -0
  36. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/renderers/tree.py +0 -0
  37. {termrender-0.7.2 → termrender-0.8.0}/src/termrender/style.py +0 -0
  38. {termrender-0.7.2 → termrender-0.8.0}/tests/__init__.py +0 -0
  39. {termrender-0.7.2 → termrender-0.8.0}/tests/test_charts.py +0 -0
  40. {termrender-0.7.2 → termrender-0.8.0}/tests/test_column_alignment.py +0 -0
  41. {termrender-0.7.2 → termrender-0.8.0}/tests/test_diff.py +0 -0
  42. {termrender-0.7.2 → termrender-0.8.0}/tests/test_inline_badge.py +0 -0
  43. {termrender-0.7.2 → termrender-0.8.0}/tests/test_myst_gaps.py +0 -0
  44. {termrender-0.7.2 → termrender-0.8.0}/tests/test_stat.py +0 -0
  45. {termrender-0.7.2 → termrender-0.8.0}/tests/test_tasklist.py +0 -0
  46. {termrender-0.7.2 → termrender-0.8.0}/tests/test_timeline.py +0 -0
  47. {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.7.2
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 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.
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
- code_lines = source.split("\n") if source else [""]
96
- block.height = len(code_lines) + 2
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 _split_directives(source: str) -> list[dict]:
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
- f"line {i + 1}: stray '{close_colons}' closer with no open directive"
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
- f"unclosed directive '{colons}{name}' missing closing '{colons}'"
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 source:
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(source, lexer, TerminalFormatter())
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 = source.split("\n") if source else [""]
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