termrender 3.0.1__tar.gz → 4.0.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 (54) hide show
  1. {termrender-3.0.1 → termrender-4.0.0}/CHANGELOG.md +57 -0
  2. {termrender-3.0.1 → termrender-4.0.0}/PKG-INFO +29 -27
  3. {termrender-3.0.1 → termrender-4.0.0}/README.md +28 -26
  4. termrender-4.0.0/src/termrender/CLAUDE.md +3 -0
  5. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/__main__.py +8 -6
  6. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/parser.py +15 -4
  7. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/CLAUDE.md +2 -0
  8. termrender-4.0.0/src/termrender/renderers/mermaid.py +142 -0
  9. {termrender-3.0.1 → termrender-4.0.0}/tests/test_mermaid_compat.py +37 -1
  10. {termrender-3.0.1 → termrender-4.0.0}/tests/test_myst_gaps.py +5 -5
  11. termrender-3.0.1/src/termrender/CLAUDE.md +0 -1
  12. termrender-3.0.1/src/termrender/renderers/mermaid.py +0 -99
  13. {termrender-3.0.1 → termrender-4.0.0}/.github/workflows/publish.yml +0 -0
  14. {termrender-3.0.1 → termrender-4.0.0}/.gitignore +0 -0
  15. {termrender-3.0.1 → termrender-4.0.0}/CLAUDE.md +0 -0
  16. {termrender-3.0.1 → termrender-4.0.0}/LICENSE +0 -0
  17. {termrender-3.0.1 → termrender-4.0.0}/design.json +0 -0
  18. {termrender-3.0.1 → termrender-4.0.0}/pyproject.toml +0 -0
  19. {termrender-3.0.1 → termrender-4.0.0}/requirements.json +0 -0
  20. {termrender-3.0.1 → termrender-4.0.0}/scripts/build-mermaid-ascii.sh +0 -0
  21. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/__init__.py +0 -0
  22. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  23. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/_mermaid_bin.py +0 -0
  24. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/blocks.py +0 -0
  25. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/emit.py +0 -0
  26. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/layout.py +0 -0
  27. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/py.typed +0 -0
  28. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/__init__.py +0 -0
  29. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/borders.py +0 -0
  30. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/charts.py +0 -0
  31. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/code.py +0 -0
  32. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/columns.py +0 -0
  33. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/diff.py +0 -0
  34. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/divider.py +0 -0
  35. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/panel.py +0 -0
  36. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/quote.py +0 -0
  37. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/stat.py +0 -0
  38. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/table.py +0 -0
  39. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/text.py +0 -0
  40. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/timeline.py +0 -0
  41. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/renderers/tree.py +0 -0
  42. {termrender-3.0.1 → termrender-4.0.0}/src/termrender/style.py +0 -0
  43. {termrender-3.0.1 → termrender-4.0.0}/tests/__init__.py +0 -0
  44. {termrender-3.0.1 → termrender-4.0.0}/tests/test_charts.py +0 -0
  45. {termrender-3.0.1 → termrender-4.0.0}/tests/test_cli_contract.py +0 -0
  46. {termrender-3.0.1 → termrender-4.0.0}/tests/test_column_alignment.py +0 -0
  47. {termrender-3.0.1 → termrender-4.0.0}/tests/test_diff.py +0 -0
  48. {termrender-3.0.1 → termrender-4.0.0}/tests/test_inline_badge.py +0 -0
  49. {termrender-3.0.1 → termrender-4.0.0}/tests/test_linebreak.py +0 -0
  50. {termrender-3.0.1 → termrender-4.0.0}/tests/test_stat.py +0 -0
  51. {termrender-3.0.1 → termrender-4.0.0}/tests/test_tasklist.py +0 -0
  52. {termrender-3.0.1 → termrender-4.0.0}/tests/test_timeline.py +0 -0
  53. {termrender-3.0.1 → termrender-4.0.0}/tests/test_variable_colons.py +0 -0
  54. {termrender-3.0.1 → termrender-4.0.0}/uv.lock +0 -0
@@ -1,6 +1,63 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.0.0 (2026-07-06)
5
+
6
+ ### Features
7
+
8
+ - **mermaid**: Also route ```mermaid fences through the mermaid renderer
9
+ ([`99a8017`](https://github.com/crouton-labs/termrender/commit/99a801783d719125505eef2dc1194e2e418ed025))
10
+
11
+ BREAKING CHANGE: partially reverses 083f590. A fenced code block whose info string is `mermaid`
12
+ (e.g. GFM ````mermaid```` fences used by GitHub and most docs/agents unaware of the :::mermaid
13
+ directive) now renders as an ASCII mermaid diagram, same as :::mermaid. Any other language tag
14
+ still falls through to a plain CODE block; the :::mermaid directive is unchanged.
15
+
16
+ Root-caused: the reported bug (fences render as raw source, not a diagram) was not a regression or
17
+ missing binary — the vendored mermaid-ascii binary and subprocess pipeline both work correctly. It
18
+ was 083f590's deliberate, documented breaking change dropping fence support. Human chose to extend
19
+ support back to fences rather than close as working-as-intended.
20
+
21
+ ### Breaking Changes
22
+
23
+ - **mermaid**: Partially reverses 083f590. A fenced code block whose info string is `mermaid` (e.g.
24
+ GFM ````mermaid```` fences used by GitHub and most docs/agents unaware of the :::mermaid
25
+ directive) now renders as an ASCII mermaid diagram, same as :::mermaid. Any other language tag
26
+ still falls through to a plain CODE block; the :::mermaid directive is unchanged.
27
+
28
+
29
+ ## v3.0.2 (2026-06-17)
30
+
31
+ ### Bug Fixes
32
+
33
+ - **docs**: Correct nested-directive examples to increasing-colon rule
34
+ ([`4332854`](https://github.com/crouton-labs/termrender/commit/4332854cc1bdab8122f7e022c2d79a69e2d8a719))
35
+
36
+ Every nested example in the README and the doc -h 'Document format' help predated 4a501d9 (require
37
+ strictly more colons on outer fences) and fails `termrender doc check`: the flagship deploy panel,
38
+ the Columns example, and the Nesting section all used equal colons. Rewrote them so each outer
39
+ directive uses strictly more colons than the one nested inside (MyST-style), documented the rule
40
+ in the intro + Nesting prose + the agent-facing doc help, and added the gotcha to src CLAUDE.md.
41
+ Rendered output is unchanged (colon count is pure syntax). All 11 README markdown blocks now pass
42
+ doc check.
43
+
44
+ - **mermaid**: Normalize flowchart node shapes to rectangles
45
+ ([`8c45f7c`](https://github.com/crouton-labs/termrender/commit/8c45f7c20810a3960cb037831a7c5f152406b3f6))
46
+
47
+ mermaid-ascii (vendored, pinned master 6fffb8e) only parses [text] rectangle nodes; every other
48
+ mermaid node shape leaked raw delimiters or the bare node id into the rendered box — B{Auth?}
49
+ rendered the literal 'B{Auth?}', E[(Database)] rendered '(Database)'. The preprocessor only
50
+ handled sequence diagrams; flowcharts passed through untouched.
51
+
52
+ Add a flowchart shape normalizer that rewrites rhombus {}, cylinder [()], circle (()), stadium ([]),
53
+ hexagon {{}}, subroutine [[]], and parallelogram/trapezoid [/ /] nodes to id[label], preserving
54
+ the label text. The backend draws a rectangle regardless, so only the text matters.
55
+
56
+ Node shapes remain unsupported on current upstream master (parseNode handles only rectangles; the 6
57
+ commits since our pin are arrow-parsing only), so this is a permanent workaround, not a stopgap
58
+ for a pin bump. Regression test + CLAUDE.md note added.
59
+
60
+
4
61
  ## v3.0.1 (2026-06-10)
5
62
 
6
63
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 3.0.1
3
+ Version: 4.0.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
@@ -77,13 +77,13 @@ Here's a realistic example — the kind of thing an LLM agent might produce afte
77
77
  The agent writes this:
78
78
 
79
79
  ```markdown
80
- :::panel{title="Deploy — api-gateway v3.2.0" color="cyan"}
80
+ :::::::panel{title="Deploy — api-gateway v3.2.0" color="cyan"}
81
81
 
82
82
  Completed at **14:32 UTC** on `prod-us-east-1`. Health checks passing.
83
83
 
84
- :::columns
85
- :::col{width="55%"}
86
- :::panel{title="Services" color="green"}
84
+ ::::::columns
85
+ :::::col{width="55%"}
86
+ ::::panel{title="Services" color="green"}
87
87
  :::tree
88
88
  api-gateway/ [x]
89
89
  auth/ [x]
@@ -94,23 +94,23 @@ worker-pool/
94
94
  scheduler/ [!]
95
95
  dead-letter/ [x]
96
96
  :::
97
- :::
98
- :::
99
- :::col{width="45%"}
100
- :::callout{type="success"}
97
+ ::::
98
+ :::::
99
+ :::::col{width="45%"}
100
+ ::::callout{type="success"}
101
101
  6 of 7 services healthy
102
- :::
102
+ ::::
103
103
 
104
- :::callout{type="warning"}
104
+ ::::callout{type="warning"}
105
105
  scheduler: 83% memory
106
106
  GC tuning shipping next release
107
- :::
107
+ ::::
108
108
 
109
109
  - **p99 latency**: 34ms
110
110
  - **error rate**: 0.02%
111
111
  - **throughput**: 12.4k req/s
112
- :::
113
- :::
112
+ :::::
113
+ ::::::
114
114
 
115
115
  :::divider{label="rollback"}
116
116
  :::
@@ -124,7 +124,7 @@ kubectl rollout status deployment/api-gateway -n prod
124
124
  :::quote{author="deploy-bot"}
125
125
  Previous stable: v3.1.4 (deployed 2025-03-28)
126
126
  :::
127
- :::
127
+ :::::::
128
128
  ```
129
129
 
130
130
  And termrender produces this:
@@ -208,7 +208,7 @@ One function. Source in, ANSI string out.
208
208
 
209
209
  ## Directives
210
210
 
211
- Triple-colon syntax with optional attributes in curly braces. They nest arbitrarily. Standard markdown works everywhere inside them.
211
+ Triple-colon syntax with optional attributes in curly braces. They nest, but an outer directive must use strictly **more** colons than the one nested inside it (`::::columns` wraps `:::col`) — see [Nesting](#nesting). Standard markdown works everywhere inside them.
212
212
 
213
213
  ```
214
214
  :::name{key="value" key2="value2"}
@@ -246,7 +246,7 @@ Side-by-side layout. Each `:::col` takes a width as a percentage.
246
246
 
247
247
  **Input:**
248
248
  ```markdown
249
- :::columns
249
+ ::::columns
250
250
  :::col{width="50%"}
251
251
  **Before**
252
252
  - Manual deploys
@@ -259,7 +259,7 @@ Side-by-side layout. Each `:::col` takes a width as a percentage.
259
259
  - 2 min rollbacks
260
260
  - Full staging env
261
261
  :::
262
- :::
262
+ ::::
263
263
  ```
264
264
 
265
265
  **Output:**
@@ -408,7 +408,7 @@ Horizontal rules with optional centered labels.
408
408
 
409
409
  ### Mermaid diagrams
410
410
 
411
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Mermaid uses the `:::mermaid` directive backtick fences (`` ```mermaid ``) are treated as plain code blocks.
411
+ Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block both render the same diagram.
412
412
 
413
413
  **Input:**
414
414
  ```markdown
@@ -427,31 +427,33 @@ The diagram gets rendered as ASCII art inline in the terminal output. Requires `
427
427
 
428
428
  Directives compose. Put a tree inside a panel, panels inside columns, callouts next to trees with a divider between sections.
429
429
 
430
+ **Nesting is by colon count: an outer directive must use strictly more colons than the directive nested inside it, and each block closes with a bare run of its own colon count on its own line.** Below, the panel uses 6 colons, the columns 5, each column 4, and the leaf tree/callout/divider 3. Innermost is always `:::` (3); add one colon per level as you go outward. (`termrender doc check` reports the exact fix when counts are off.)
431
+
430
432
  **Input:**
431
433
  ```markdown
432
- :::panel{title="Deployment" color="green"}
434
+ ::::::panel{title="Deployment" color="green"}
433
435
 
434
- :::columns
435
- :::col{width="60%"}
436
+ :::::columns
437
+ ::::col{width="60%"}
436
438
  :::tree
437
439
  services/
438
440
  api/ [x]
439
441
  worker/ [x]
440
442
  scheduler/ [!]
441
443
  :::
442
- :::
443
- :::col{width="40%"}
444
+ ::::
445
+ ::::col{width="40%"}
444
446
  :::callout{type="success"}
445
447
  2 of 3 services deployed
446
448
  :::
447
- :::
448
- :::
449
+ ::::
450
+ :::::
449
451
 
450
452
  :::divider{label="logs"}
451
453
  :::
452
454
 
453
455
  Last deploy: `api@v2.4.1` at 14:32 UTC
454
- :::
456
+ ::::::
455
457
  ```
456
458
 
457
459
  A bordered panel containing two columns (file tree on the left, status callout on the right), a labeled divider, and a status line. All from nested directives.
@@ -49,13 +49,13 @@ Here's a realistic example — the kind of thing an LLM agent might produce afte
49
49
  The agent writes this:
50
50
 
51
51
  ```markdown
52
- :::panel{title="Deploy — api-gateway v3.2.0" color="cyan"}
52
+ :::::::panel{title="Deploy — api-gateway v3.2.0" color="cyan"}
53
53
 
54
54
  Completed at **14:32 UTC** on `prod-us-east-1`. Health checks passing.
55
55
 
56
- :::columns
57
- :::col{width="55%"}
58
- :::panel{title="Services" color="green"}
56
+ ::::::columns
57
+ :::::col{width="55%"}
58
+ ::::panel{title="Services" color="green"}
59
59
  :::tree
60
60
  api-gateway/ [x]
61
61
  auth/ [x]
@@ -66,23 +66,23 @@ worker-pool/
66
66
  scheduler/ [!]
67
67
  dead-letter/ [x]
68
68
  :::
69
- :::
70
- :::
71
- :::col{width="45%"}
72
- :::callout{type="success"}
69
+ ::::
70
+ :::::
71
+ :::::col{width="45%"}
72
+ ::::callout{type="success"}
73
73
  6 of 7 services healthy
74
- :::
74
+ ::::
75
75
 
76
- :::callout{type="warning"}
76
+ ::::callout{type="warning"}
77
77
  scheduler: 83% memory
78
78
  GC tuning shipping next release
79
- :::
79
+ ::::
80
80
 
81
81
  - **p99 latency**: 34ms
82
82
  - **error rate**: 0.02%
83
83
  - **throughput**: 12.4k req/s
84
- :::
85
- :::
84
+ :::::
85
+ ::::::
86
86
 
87
87
  :::divider{label="rollback"}
88
88
  :::
@@ -96,7 +96,7 @@ kubectl rollout status deployment/api-gateway -n prod
96
96
  :::quote{author="deploy-bot"}
97
97
  Previous stable: v3.1.4 (deployed 2025-03-28)
98
98
  :::
99
- :::
99
+ :::::::
100
100
  ```
101
101
 
102
102
  And termrender produces this:
@@ -180,7 +180,7 @@ One function. Source in, ANSI string out.
180
180
 
181
181
  ## Directives
182
182
 
183
- Triple-colon syntax with optional attributes in curly braces. They nest arbitrarily. Standard markdown works everywhere inside them.
183
+ Triple-colon syntax with optional attributes in curly braces. They nest, but an outer directive must use strictly **more** colons than the one nested inside it (`::::columns` wraps `:::col`) — see [Nesting](#nesting). Standard markdown works everywhere inside them.
184
184
 
185
185
  ```
186
186
  :::name{key="value" key2="value2"}
@@ -218,7 +218,7 @@ Side-by-side layout. Each `:::col` takes a width as a percentage.
218
218
 
219
219
  **Input:**
220
220
  ```markdown
221
- :::columns
221
+ ::::columns
222
222
  :::col{width="50%"}
223
223
  **Before**
224
224
  - Manual deploys
@@ -231,7 +231,7 @@ Side-by-side layout. Each `:::col` takes a width as a percentage.
231
231
  - 2 min rollbacks
232
232
  - Full staging env
233
233
  :::
234
- :::
234
+ ::::
235
235
  ```
236
236
 
237
237
  **Output:**
@@ -380,7 +380,7 @@ Horizontal rules with optional centered labels.
380
380
 
381
381
  ### Mermaid diagrams
382
382
 
383
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Mermaid uses the `:::mermaid` directive backtick fences (`` ```mermaid ``) are treated as plain code blocks.
383
+ Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block both render the same diagram.
384
384
 
385
385
  **Input:**
386
386
  ```markdown
@@ -399,31 +399,33 @@ The diagram gets rendered as ASCII art inline in the terminal output. Requires `
399
399
 
400
400
  Directives compose. Put a tree inside a panel, panels inside columns, callouts next to trees with a divider between sections.
401
401
 
402
+ **Nesting is by colon count: an outer directive must use strictly more colons than the directive nested inside it, and each block closes with a bare run of its own colon count on its own line.** Below, the panel uses 6 colons, the columns 5, each column 4, and the leaf tree/callout/divider 3. Innermost is always `:::` (3); add one colon per level as you go outward. (`termrender doc check` reports the exact fix when counts are off.)
403
+
402
404
  **Input:**
403
405
  ```markdown
404
- :::panel{title="Deployment" color="green"}
406
+ ::::::panel{title="Deployment" color="green"}
405
407
 
406
- :::columns
407
- :::col{width="60%"}
408
+ :::::columns
409
+ ::::col{width="60%"}
408
410
  :::tree
409
411
  services/
410
412
  api/ [x]
411
413
  worker/ [x]
412
414
  scheduler/ [!]
413
415
  :::
414
- :::
415
- :::col{width="40%"}
416
+ ::::
417
+ ::::col{width="40%"}
416
418
  :::callout{type="success"}
417
419
  2 of 3 services deployed
418
420
  :::
419
- :::
420
- :::
421
+ ::::
422
+ :::::
421
423
 
422
424
  :::divider{label="logs"}
423
425
  :::
424
426
 
425
427
  Last deploy: `api@v2.4.1` at 14:32 UTC
426
- :::
428
+ ::::::
427
429
  ```
428
430
 
429
431
  A bordered panel containing two columns (file tree on the left, status callout on the right), a labeled divider, and a status line. All from nested directives.
@@ -0,0 +1,3 @@
1
+ - **Nesting is by colon count** (`parser.py`, since `4a501d9`): an outer directive must use strictly MORE colons than the directive nested inside it — `::::columns` (4) wraps `:::col` (3); a leaf closes with a bare run of its own colon count. Equal-colon nesting is a `syntax_error` (the closer matches the wrong opener). Mirrors MyST colon fences. All docs/examples must follow this — `termrender doc check` is the gate.
2
+ - **QUOTE** height gets `+1` only when `author` or `by` attr is set. Using any other key (`attribution`, `source`) silently omits the extra line — the renderer's attribution line is clipped.
3
+ - **``` mermaid fences and `:::mermaid` both produce `BlockType.MERMAID`** (`parser.py::_convert_ast`, block_code handling): a fenced code block whose info string's first word is `mermaid` (case-insensitive) is special-cased to `BlockType.MERMAID` instead of `BlockType.CODE`, so GFM-style ``` ```mermaid ``` docs render the same as the directive form. This was a deliberate reversal of `083f590` (which had dropped fence support); any other language tag still falls through to a plain CODE block.
@@ -63,20 +63,22 @@ Branches
63
63
 
64
64
  Document format
65
65
  Directive-flavored markdown. Triple-colon blocks carry attributes in {} and
66
- nest arbitrarily; every block closes with a bare `:::`. Plain markdown
67
- (headings, **bold**, *italic*, `code`, bullet/numbered lists) works
68
- everywhere, including inside directives. Write structure, not box-drawing —
69
- layout, padding, and guide lines are computed.
66
+ nest by COLON COUNT: an outer block needs strictly MORE colons than the block
67
+ nested inside it (::::columns wraps :::col), and each block closes with a bare
68
+ run of its own colon count on its own line. Plain markdown (headings, **bold**,
69
+ *italic*, `code`, bullet/numbered lists) works everywhere, including inside
70
+ directives. Write structure, not box-drawing — layout, padding, and guide
71
+ lines are computed.
70
72
 
71
73
  :::panel{title="" color=""} bordered box; color e.g. green|cyan|red
72
- :::columns / :::col{width="N%"} side-by-side; col widths sum to 100%
74
+ ::::columns / :::col{width="N%"} side-by-side; col widths sum to 100%
73
75
  :::tree{color=""} indentation → Unicode guide lines;
74
76
  [x]=done [!]=warning status markers
75
77
  :::callout{type=""} admonition; type success|warning|error|info
76
78
  :::divider{label=""} labeled horizontal rule
77
79
  :::code{lang=""} syntax-highlighted block (``` fences also work)
78
80
  :::quote{author=""} attributed block quote
79
- :::mermaid flowchart → ASCII (NOT ```mermaid fences)
81
+ :::mermaid flowchart → ASCII (``` mermaid fences also work)
80
82
  """
81
83
 
82
84
  _DOC_RENDER_HELP = """\
@@ -303,10 +303,21 @@ def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
303
303
  elif ntype == "block_code":
304
304
  raw = node.get("raw", "")
305
305
  info = node.get("attrs", {}).get("info", "")
306
- blocks.append(Block(
307
- type=BlockType.CODE,
308
- attrs={"lang": info, "source": raw},
309
- ))
306
+ # A ```mermaid fence is GFM's standard way to tag a mermaid diagram
307
+ # (GitHub, and most docs/agents that don't know this project's
308
+ # :::mermaid directive, use this form) — route it through the
309
+ # mermaid renderer same as :::mermaid, instead of a plain code panel.
310
+ lang = info.split()[0].lower() if info.split() else ""
311
+ if lang == "mermaid":
312
+ blocks.append(Block(
313
+ type=BlockType.MERMAID,
314
+ attrs={"source": raw},
315
+ ))
316
+ else:
317
+ blocks.append(Block(
318
+ type=BlockType.CODE,
319
+ attrs={"lang": info, "source": raw},
320
+ ))
310
321
 
311
322
  elif ntype == "list":
312
323
  ordered = node.get("attrs", {}).get("ordered", False)
@@ -53,4 +53,6 @@ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by
53
53
 
54
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.
55
55
 
56
+ **Flowchart node shapes are normalized to rectangles**: `preprocess_mermaid_for_ascii` rewrites every non-rectangle flowchart node shape (`{rhombus}`, `[(cylinder)]`, `((circle))`, `([stadium])`, `{{hexagon}}`, `[[subroutine]]`, `[/parallelogram/]`) to the `id[label]` form via `_FLOWCHART_SHAPE_SUBS`, because mermaid-ascii's `parseNode` only recognizes `[text]` rectangles — other shapes otherwise leak raw delimiters or the bare node id into the box. This is a permanent workaround: node shapes are unsupported on current upstream master (pin `6fffb8e`) and the 6 newer master commits are arrow-parsing only, so don't expect a pin bump to remove the need. The backend draws a rectangle regardless, so only the label text matters.
57
+
56
58
  **Binary resolution & no width flag**: the executable comes from `_mermaid_bin.mermaid_ascii_bin()` — the vendored `_bin/mermaid-ascii-<os>-<arch>` (pinned upstream master `6fffb8e`, built via `scripts/build-mermaid-ascii.sh`) if present for the platform, else `mermaid-ascii` on PATH (the PyPI wheel, capped at 1.2.0). **`mermaid-ascii` has no width-control flag** (never has — `--maxWidth`/`-w` do not exist); the call passes only `-f - -y 1`, so `block.width` does not constrain the diagram and wide output overflows (compounding the no-truncation gotcha above). A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on **both** 1.2.0 and master; the non-zero exit is caught and the block degrades to raw source.
@@ -0,0 +1,142 @@
1
+ """Mermaid diagram renderer for termrender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+
8
+ from termrender._mermaid_bin import mermaid_ascii_bin
9
+ from termrender.blocks import Block
10
+ from termrender.style import visual_ljust
11
+
12
+
13
+ def fix_mermaid_encoding(text: str) -> str:
14
+ """Undo mermaid-ascii's double-encoding of UTF-8 characters.
15
+
16
+ mermaid-ascii misinterprets UTF-8 input bytes as Latin-1 and re-encodes
17
+ to UTF-8, corrupting multi-byte characters (e.g. → becomes â\\x86\\x92).
18
+ Reversing the process: encode back to Latin-1 to recover the original
19
+ UTF-8 bytes, then decode as UTF-8.
20
+ """
21
+ try:
22
+ return text.encode("latin-1").decode("utf-8")
23
+ except (UnicodeDecodeError, UnicodeEncodeError):
24
+ return text
25
+
26
+
27
+ _NOTE_RE = re.compile(
28
+ r"^(\s*)[Nn]ote\s+(?:over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
29
+ )
30
+ _BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
31
+
32
+ # Flowchart node-shape normalizers. mermaid-ascii only parses the ``[text]``
33
+ # rectangle form; every other mermaid node shape leaks its raw delimiters (or
34
+ # the node id) into the rendered box — e.g. ``B{Auth?}`` renders the literal
35
+ # ``B{Auth?}`` and ``E[(Database)]`` renders ``(Database)``. Since mermaid-ascii
36
+ # draws every node as a rectangle regardless, we rewrite each alternate shape to
37
+ # ``id[label]``, preserving the label text and dropping only the shape (which
38
+ # the ASCII backend cannot draw anyway). Order matters: multi-char delimiters
39
+ # (``{{``, ``[[``, ``[(``, ``((``, ``([``) must run before their single-char
40
+ # counterparts so the greedy single forms don't bite off half a delimiter.
41
+ _FLOWCHART_SHAPE_SUBS: list[tuple[re.Pattern[str], str]] = [
42
+ (re.compile(r"(\w+)\{\{(.+?)\}\}"), r"\1[\2]"), # hexagon {{ }}
43
+ (re.compile(r"(\w+)\[\[(.+?)\]\]"), r"\1[\2]"), # subroutine [[ ]]
44
+ (re.compile(r"(\w+)\[\((.+?)\)\]"), r"\1[\2]"), # cylinder [( )]
45
+ (re.compile(r"(\w+)\(\(\((.+?)\)\)\)"), r"\1[\2]"), # double circle ((( )))
46
+ (re.compile(r"(\w+)\(\((.+?)\)\)"), r"\1[\2]"), # circle (( ))
47
+ (re.compile(r"(\w+)\(\[(.+?)\]\)"), r"\1[\2]"), # stadium ([ ])
48
+ (re.compile(r"(\w+)\[[\\/](.+?)[\\/]\]"), r"\1[\2]"), # parallelogram/trapezoid [/ /] [\ \] [/ \] [\ /]
49
+ (re.compile(r"(\w+)\{(.+?)\}"), r"\1[\2]"), # rhombus { }
50
+ (re.compile(r"(\w+)>(.+?)\]"), r"\1[\2]"), # asymmetric/flag > ]
51
+ (re.compile(r"(\w+)\((.+?)\)"), r"\1[\2]"), # round ( )
52
+ ]
53
+ _UNSUPPORTED_BLOCK_RE = re.compile(
54
+ r"^\s*(?:loop|alt|else|opt|par|and|critical|option|break|rect|"
55
+ r"activate|deactivate|autonumber|end)\b.*$",
56
+ re.IGNORECASE,
57
+ )
58
+
59
+
60
+ def normalize_flowchart_shapes(source: str) -> str:
61
+ """Rewrite alternate flowchart node shapes to the ``[text]`` form.
62
+
63
+ mermaid-ascii only parses rectangle nodes (``id[text]``); rhombus ``{}``,
64
+ cylinder ``[()]``, circle ``(())``, stadium ``([])``, hexagon ``{{}}``,
65
+ subroutine ``[[]]``, and parallelogram/trapezoid ``[/ /]`` nodes otherwise
66
+ render with their raw delimiters or bare node id. Each is rewritten to
67
+ ``id[text]`` so the label survives; the backend draws a rectangle either way.
68
+ """
69
+ out: list[str] = []
70
+ for line in source.splitlines():
71
+ for pattern, repl in _FLOWCHART_SHAPE_SUBS:
72
+ line = pattern.sub(repl, line)
73
+ out.append(line)
74
+ return "\n".join(out)
75
+
76
+
77
+ def preprocess_mermaid_for_ascii(source: str) -> str:
78
+ """Rewrite a mermaid diagram into the subset mermaid-ascii supports.
79
+
80
+ For sequence diagrams: convert ``Note`` lines into self-loops, map the
81
+ arrow variants (``->``, ``-x``, ``--x``, ``-)``, ``--)``, ``-->``) to the
82
+ supported ``->>``/``-->>`` pair, drop block keywords (``loop``, ``alt``,
83
+ ``activate``…), and flatten ``<br/>`` tags. For flowcharts (``graph`` /
84
+ ``flowchart``): normalize alternate node shapes to ``[text]`` rectangles.
85
+ Other diagram types are returned unchanged.
86
+ """
87
+ lines = source.splitlines()
88
+ first = next((l.strip() for l in lines if l.strip()), "")
89
+ first_lower = first.lower()
90
+ if not first_lower.startswith("sequencediagram"):
91
+ if first_lower.startswith(("graph", "flowchart")):
92
+ return normalize_flowchart_shapes(source)
93
+ return source
94
+
95
+ out: list[str] = []
96
+ for line in lines:
97
+ m = _NOTE_RE.match(line)
98
+ if m:
99
+ indent, parts, msg = m.group(1), m.group(2), m.group(3)
100
+ first_p = parts.split(",")[0].strip()
101
+ msg = _BR_RE.sub(" / ", msg)
102
+ out.append(f"{indent}{first_p}->>{first_p}: 📝 {msg}")
103
+ continue
104
+
105
+ if _UNSUPPORTED_BLOCK_RE.match(line):
106
+ continue
107
+
108
+ line = _BR_RE.sub(" / ", line)
109
+ line = re.sub(r"--x(?=\s|\w|\()", "-->>", line)
110
+ line = re.sub(r"-x(?=\s|\w|\()", "->>", line)
111
+ line = re.sub(r"--\)(?=\s|\w|\()", "-->>", line)
112
+ line = re.sub(r"-\)(?=\s|\w|\()", "->>", line)
113
+ line = re.sub(r"-->(?!>)", "-->>", line)
114
+ line = re.sub(r"(?<!-)->(?!>)", "->>", line)
115
+ out.append(line)
116
+ return "\n".join(out)
117
+
118
+
119
+ def render(block: Block, color: bool) -> list[str]:
120
+ """Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
121
+ w = block.width
122
+ rendered = block.attrs.get("_rendered")
123
+
124
+ if rendered is None:
125
+ source = block.attrs.get("source", "")
126
+ try:
127
+ result = subprocess.run(
128
+ [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
129
+ input=preprocess_mermaid_for_ascii(source),
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=30,
133
+ )
134
+ rendered = fix_mermaid_encoding(result.stdout)
135
+ except Exception:
136
+ rendered = source
137
+
138
+ lines: list[str] = []
139
+ for raw_line in rendered.split("\n"):
140
+ lines.append(visual_ljust(raw_line, w))
141
+
142
+ return lines
@@ -5,10 +5,46 @@ from termrender.renderers.mermaid import preprocess_mermaid_for_ascii
5
5
 
6
6
  class TestMermaidPreprocessor(unittest.TestCase):
7
7
 
8
- def test_non_sequence_diagrams_pass_through(self):
8
+ def test_unknown_diagram_types_pass_through(self):
9
+ src = "pie\n \"A\" : 40\n \"B\" : 60"
10
+ self.assertEqual(preprocess_mermaid_for_ascii(src), src)
11
+
12
+ def test_flowchart_plain_edges_pass_through(self):
9
13
  src = "flowchart TD\n A-->B\n B-->C"
10
14
  self.assertEqual(preprocess_mermaid_for_ascii(src), src)
11
15
 
16
+ # Regression: mermaid-ascii (pinned master 6fffb8e) only parses ``[text]``
17
+ # rectangle nodes — every other mermaid node shape leaked its raw delimiters
18
+ # or bare node id into the rendered box (``B{Auth?}`` rendered the literal
19
+ # ``B{Auth?}``; ``E[(Database)]`` rendered ``(Database)``). Node shapes are
20
+ # still unsupported upstream (parseNode only handles rectangles), so we
21
+ # normalize every shape to ``id[label]`` on our side.
22
+ def test_flowchart_node_shapes_normalized_to_rectangles(self):
23
+ src = (
24
+ "graph LR\n"
25
+ " A[Client] --> B{Auth?}\n"
26
+ " B -->|Yes| C[Handler]\n"
27
+ " C --> E[(Database)]\n"
28
+ " F((Circle)) --> G([Stadium])\n"
29
+ " G --> H{{Hex}}\n"
30
+ " H --> I[[Sub]]\n"
31
+ " I --> J[/Para/]"
32
+ )
33
+ out = preprocess_mermaid_for_ascii(src)
34
+ self.assertIn("B[Auth?]", out)
35
+ self.assertIn("E[Database]", out)
36
+ self.assertIn("F[Circle]", out)
37
+ self.assertIn("G[Stadium]", out)
38
+ self.assertIn("H[Hex]", out)
39
+ self.assertIn("I[Sub]", out)
40
+ self.assertIn("J[Para]", out)
41
+ # Rectangle nodes and edge labels are untouched.
42
+ self.assertIn("A[Client]", out)
43
+ self.assertIn("|Yes|", out)
44
+ # No raw alternate delimiters survive.
45
+ for leak in ("{Auth?}", "[(", "((", "))", "{{", "}}", "[[", "]]"):
46
+ self.assertNotIn(leak, out)
47
+
12
48
  def test_note_over_becomes_self_loop(self):
13
49
  src = "sequenceDiagram\n participant A\n participant B\n Note over A: hello"
14
50
  out = preprocess_mermaid_for_ascii(src)
@@ -7,7 +7,7 @@ from termrender.parser import parse, _strip_options
7
7
 
8
8
 
9
9
  class TestMermaidDirective(unittest.TestCase):
10
- """:::mermaid is the only fence form for mermaid diagrams."""
10
+ """:::mermaid and ```mermaid fences both produce mermaid diagrams."""
11
11
 
12
12
  def test_basic_mermaid_directive(self):
13
13
  """:::mermaid\ngraph LR\nA-->B\n::: → BlockType.MERMAID"""
@@ -26,12 +26,12 @@ class TestMermaidDirective(unittest.TestCase):
26
26
  self.assertIn("graph LR", block.attrs["source"])
27
27
  self.assertNotIn(":title:", block.attrs["source"])
28
28
 
29
- def test_backtick_mermaid_is_plain_code_block(self):
30
- """```mermaid is no longer a mermaid block it renders as a code block."""
29
+ def test_backtick_mermaid_is_mermaid_block(self):
30
+ """```mermaid fences route through the mermaid renderer, same as :::mermaid."""
31
31
  doc = parse("```mermaid\ngraph LR\nA-->B\n```")
32
32
  block = doc.children[0]
33
- self.assertEqual(block.type, BlockType.CODE)
34
- self.assertEqual(block.attrs.get("lang"), "mermaid")
33
+ self.assertEqual(block.type, BlockType.MERMAID)
34
+ self.assertIn("graph LR", block.attrs["source"])
35
35
 
36
36
  def test_backtick_directive_is_plain_code_block(self):
37
37
  """```{panel} is no longer a directive — it renders as a code block."""
@@ -1 +0,0 @@
1
- - **QUOTE** height gets `+1` only when `author` or `by` attr is set. Using any other key (`attribution`, `source`) silently omits the extra line — the renderer's attribution line is clipped.
@@ -1,99 +0,0 @@
1
- """Mermaid diagram renderer for termrender."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- import subprocess
7
-
8
- from termrender._mermaid_bin import mermaid_ascii_bin
9
- from termrender.blocks import Block
10
- from termrender.style import visual_ljust
11
-
12
-
13
- def fix_mermaid_encoding(text: str) -> str:
14
- """Undo mermaid-ascii's double-encoding of UTF-8 characters.
15
-
16
- mermaid-ascii misinterprets UTF-8 input bytes as Latin-1 and re-encodes
17
- to UTF-8, corrupting multi-byte characters (e.g. → becomes â\\x86\\x92).
18
- Reversing the process: encode back to Latin-1 to recover the original
19
- UTF-8 bytes, then decode as UTF-8.
20
- """
21
- try:
22
- return text.encode("latin-1").decode("utf-8")
23
- except (UnicodeDecodeError, UnicodeEncodeError):
24
- return text
25
-
26
-
27
- _NOTE_RE = re.compile(
28
- r"^(\s*)[Nn]ote\s+(?:over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
29
- )
30
- _BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
31
- _UNSUPPORTED_BLOCK_RE = re.compile(
32
- r"^\s*(?:loop|alt|else|opt|par|and|critical|option|break|rect|"
33
- r"activate|deactivate|autonumber|end)\b.*$",
34
- re.IGNORECASE,
35
- )
36
-
37
-
38
- def preprocess_mermaid_for_ascii(source: str) -> str:
39
- """Rewrite mermaid sequence diagrams into the subset mermaid-ascii supports.
40
-
41
- mermaid-ascii only understands ``->>`` and ``-->>`` arrows plus ``participant``
42
- declarations. This helper converts ``Note`` lines into self-loops, maps the
43
- other arrow variants (``->``, ``-x``, ``--x``, ``-)``, ``--)``, ``-->``) to
44
- the supported pair, drops block keywords (``loop``, ``alt``, ``activate``…),
45
- and flattens ``<br/>`` tags. Non-sequence diagrams are returned unchanged.
46
- """
47
- lines = source.splitlines()
48
- first = next((l.strip() for l in lines if l.strip()), "")
49
- if not first.lower().startswith("sequencediagram"):
50
- return source
51
-
52
- out: list[str] = []
53
- for line in lines:
54
- m = _NOTE_RE.match(line)
55
- if m:
56
- indent, parts, msg = m.group(1), m.group(2), m.group(3)
57
- first_p = parts.split(",")[0].strip()
58
- msg = _BR_RE.sub(" / ", msg)
59
- out.append(f"{indent}{first_p}->>{first_p}: 📝 {msg}")
60
- continue
61
-
62
- if _UNSUPPORTED_BLOCK_RE.match(line):
63
- continue
64
-
65
- line = _BR_RE.sub(" / ", line)
66
- line = re.sub(r"--x(?=\s|\w|\()", "-->>", line)
67
- line = re.sub(r"-x(?=\s|\w|\()", "->>", line)
68
- line = re.sub(r"--\)(?=\s|\w|\()", "-->>", line)
69
- line = re.sub(r"-\)(?=\s|\w|\()", "->>", line)
70
- line = re.sub(r"-->(?!>)", "-->>", line)
71
- line = re.sub(r"(?<!-)->(?!>)", "->>", line)
72
- out.append(line)
73
- return "\n".join(out)
74
-
75
-
76
- def render(block: Block, color: bool) -> list[str]:
77
- """Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
78
- w = block.width
79
- rendered = block.attrs.get("_rendered")
80
-
81
- if rendered is None:
82
- source = block.attrs.get("source", "")
83
- try:
84
- result = subprocess.run(
85
- [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
86
- input=preprocess_mermaid_for_ascii(source),
87
- capture_output=True,
88
- text=True,
89
- timeout=30,
90
- )
91
- rendered = fix_mermaid_encoding(result.stdout)
92
- except Exception:
93
- rendered = source
94
-
95
- lines: list[str] = []
96
- for raw_line in rendered.split("\n"):
97
- lines.append(visual_ljust(raw_line, w))
98
-
99
- 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