termrender 4.3.1__tar.gz → 4.3.2__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 (68) hide show
  1. {termrender-4.3.1 → termrender-4.3.2}/CHANGELOG.md +20 -0
  2. {termrender-4.3.1 → termrender-4.3.2}/PKG-INFO +1 -1
  3. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid.py +13 -3
  4. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_journey.py +4 -1
  5. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_mindmap.py +10 -6
  6. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_timeline.py +4 -1
  7. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_dispatch.py +30 -0
  8. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_journey.py +57 -0
  9. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_mindmap.py +22 -0
  10. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_timeline.py +28 -0
  11. {termrender-4.3.1 → termrender-4.3.2}/.github/workflows/publish.yml +0 -0
  12. {termrender-4.3.1 → termrender-4.3.2}/.gitignore +0 -0
  13. {termrender-4.3.1 → termrender-4.3.2}/CLAUDE.md +0 -0
  14. {termrender-4.3.1 → termrender-4.3.2}/LICENSE +0 -0
  15. {termrender-4.3.1 → termrender-4.3.2}/README.md +0 -0
  16. {termrender-4.3.1 → termrender-4.3.2}/design.json +0 -0
  17. {termrender-4.3.1 → termrender-4.3.2}/pyproject.toml +0 -0
  18. {termrender-4.3.1 → termrender-4.3.2}/requirements.json +0 -0
  19. {termrender-4.3.1 → termrender-4.3.2}/scripts/build-mermaid-ascii.sh +0 -0
  20. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/CLAUDE.md +0 -0
  21. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/__init__.py +0 -0
  22. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/__main__.py +0 -0
  23. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  24. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/_mermaid_bin.py +0 -0
  25. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/blocks.py +0 -0
  26. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/emit.py +0 -0
  27. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/layout.py +0 -0
  28. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/parser.py +0 -0
  29. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/py.typed +0 -0
  30. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/CLAUDE.md +0 -0
  31. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/__init__.py +0 -0
  32. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/borders.py +0 -0
  33. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/charts.py +0 -0
  34. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/code.py +0 -0
  35. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/columns.py +0 -0
  36. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/diff.py +0 -0
  37. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/divider.py +0 -0
  38. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  39. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
  40. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_pie.py +0 -0
  41. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
  42. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
  43. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/panel.py +0 -0
  44. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/quote.py +0 -0
  45. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/stat.py +0 -0
  46. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/table.py +0 -0
  47. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/text.py +0 -0
  48. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/timeline.py +0 -0
  49. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/renderers/tree.py +0 -0
  50. {termrender-4.3.1 → termrender-4.3.2}/src/termrender/style.py +0 -0
  51. {termrender-4.3.1 → termrender-4.3.2}/tests/__init__.py +0 -0
  52. {termrender-4.3.1 → termrender-4.3.2}/tests/test_charts.py +0 -0
  53. {termrender-4.3.1 → termrender-4.3.2}/tests/test_cli_contract.py +0 -0
  54. {termrender-4.3.1 → termrender-4.3.2}/tests/test_column_alignment.py +0 -0
  55. {termrender-4.3.1 → termrender-4.3.2}/tests/test_diff.py +0 -0
  56. {termrender-4.3.1 → termrender-4.3.2}/tests/test_inline_badge.py +0 -0
  57. {termrender-4.3.1 → termrender-4.3.2}/tests/test_linebreak.py +0 -0
  58. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_compat.py +0 -0
  59. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_flow_model.py +0 -0
  60. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_gantt.py +0 -0
  61. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_pie.py +0 -0
  62. {termrender-4.3.1 → termrender-4.3.2}/tests/test_mermaid_sequence.py +0 -0
  63. {termrender-4.3.1 → termrender-4.3.2}/tests/test_myst_gaps.py +0 -0
  64. {termrender-4.3.1 → termrender-4.3.2}/tests/test_stat.py +0 -0
  65. {termrender-4.3.1 → termrender-4.3.2}/tests/test_tasklist.py +0 -0
  66. {termrender-4.3.1 → termrender-4.3.2}/tests/test_timeline.py +0 -0
  67. {termrender-4.3.1 → termrender-4.3.2}/tests/test_variable_colons.py +0 -0
  68. {termrender-4.3.1 → termrender-4.3.2}/uv.lock +0 -0
@@ -1,6 +1,26 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.3.2 (2026-07-06)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid**: Strip %% comments/directives from journey/mindmap/timeline
9
+ ([`d61f113`](https://github.com/crouton-labs/termrender/commit/d61f113cf750c9379f00825e2f6fc7b3898e7b8f))
10
+
11
+ The dispatcher (mermaid.py) only used strip_prelude_lines() for type sniffing, then passed the
12
+ untouched original source to the native renderers, so a leading %%{init}%% directive or a mid-body
13
+ %% comment leaked into rendered output as fake tree nodes, task rows, or timeline events in the
14
+ journey/mindmap/timeline renderers.
15
+
16
+ - mermaid.py: pass prelude-stripped source to the three native renderers; the Go-binary fallback
17
+ path keeps receiving the raw source untouched. - mermaid_journey.py, mermaid_mindmap.py,
18
+ mermaid_timeline.py: skip %%-prefixed lines anywhere in the body, not just the leading prelude,
19
+ mirroring mermaid_sequence.py's existing handling.
20
+
21
+ Adds regression tests pinning each confirmed repro to render nothing from the comment/directive.
22
+
23
+
4
24
  ## v4.3.1 (2026-07-06)
5
25
 
6
26
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.3.1
3
+ Version: 4.3.2
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
@@ -139,6 +139,16 @@ def _first_line_type(source: str) -> str:
139
139
  return first.lower()
140
140
 
141
141
 
142
+ def _stripped(source: str) -> str:
143
+ """Return ``source`` with its leading prelude (comments/directives/
144
+ frontmatter) removed, for native renderers that don't otherwise skip it.
145
+
146
+ The Go-binary fallback path (``_render_via_binary``) must keep receiving
147
+ the untouched original source — its behavior stays byte-for-byte.
148
+ """
149
+ return "\n".join(strip_prelude_lines(source.splitlines()))
150
+
151
+
142
152
  def _render_via_binary(source: str) -> str:
143
153
  """Render via the vendored mermaid-ascii binary, falling back to source.
144
154
 
@@ -182,11 +192,11 @@ def render_mermaid_lines(source: str, width: int) -> list[str]:
182
192
  if diagram_type.startswith("sequencediagram"):
183
193
  return mermaid_sequence.render_sequence(source, width)
184
194
  if diagram_type.startswith("mindmap"):
185
- return mermaid_mindmap.render(source, width)
195
+ return mermaid_mindmap.render(_stripped(source), width)
186
196
  if diagram_type.startswith("journey"):
187
- return mermaid_journey.render(source, width)
197
+ return mermaid_journey.render(_stripped(source), width)
188
198
  if diagram_type.startswith("timeline"):
189
- return mermaid_timeline.render(source, width)
199
+ return mermaid_timeline.render(_stripped(source), width)
190
200
  return _render_via_binary(source).split("\n")
191
201
 
192
202
 
@@ -40,7 +40,8 @@ def parse_journey(source: str) -> dict:
40
40
  still captured with ``score=None``; a non-numeric score is likewise
41
41
  tolerated (``score=None``) rather than raising. A task line before any
42
42
  ``section`` header lands in an unnamed leading section. Sections with
43
- no tasks are dropped.
43
+ no tasks are dropped. ``%%`` comment and directive lines are skipped
44
+ wherever they appear in the body, not just in the leading prelude.
44
45
 
45
46
  Returns ``{"title": str | None, "sections": [{"name": str | None,
46
47
  "tasks": [{"name": str, "score": int | None, "actors": list[str]}]}]}``.
@@ -51,6 +52,8 @@ def parse_journey(source: str) -> dict:
51
52
  for line in source.splitlines():
52
53
  if not line.strip() or _HEADER_RE.match(line):
53
54
  continue
55
+ if line.strip().startswith("%%"):
56
+ continue
54
57
 
55
58
  m = _TITLE_RE.match(line)
56
59
  if m:
@@ -64,12 +64,14 @@ def _node_label(text: str) -> str:
64
64
  def parse_mindmap(source: str) -> str:
65
65
  """Parse mermaid mindmap syntax into ``tree.py``'s plain indented format.
66
66
 
67
- Drops the ``mindmap`` header line and any ``::icon(...)``/``:::class``
68
- decoration lines (termrender's tree has no icon/class concept); every
69
- other line's original indentation is preserved (depth comes from
70
- indentation, exactly as ``tree.py`` already expects) with its node-shape
71
- delimiters stripped down to label text. Returns the transformed
72
- multi-line indented string, empty if nothing survived.
67
+ Drops the ``mindmap`` header line, any ``%%`` comment/directive line
68
+ (wherever it appears in the body, not just in the leading prelude), and
69
+ any ``::icon(...)``/``:::class`` decoration lines (termrender's tree has
70
+ no icon/class concept); every other line's original indentation is
71
+ preserved (depth comes from indentation, exactly as ``tree.py`` already
72
+ expects) with its node-shape delimiters stripped down to label text.
73
+ Returns the transformed multi-line indented string, empty if nothing
74
+ survived.
73
75
  """
74
76
  out_lines: list[str] = []
75
77
  for raw_line in source.splitlines():
@@ -78,6 +80,8 @@ def parse_mindmap(source: str) -> str:
78
80
  if _HEADER_RE.match(raw_line):
79
81
  continue
80
82
  stripped = raw_line.strip()
83
+ if stripped.startswith("%%"):
84
+ continue
81
85
  if _ICON_RE.match(stripped) or _CLASS_RE.match(stripped):
82
86
  continue
83
87
  indent = raw_line[: len(raw_line) - len(raw_line.lstrip(" "))]
@@ -44,7 +44,8 @@ def parse_timeline(source: str) -> dict:
44
44
 
45
45
  Returns ``{"title": str | None, "sections": [{"name": str | None,
46
46
  "entries": [{"date": str, "event": str}]}]}``. Sections with no
47
- entries are dropped.
47
+ entries are dropped. ``%%`` comment and directive lines are skipped
48
+ wherever they appear in the body, not just in the leading prelude.
48
49
  """
49
50
  title: str | None = None
50
51
  sections: list[dict] = [{"name": None, "entries": []}]
@@ -53,6 +54,8 @@ def parse_timeline(source: str) -> dict:
53
54
  for line in source.splitlines():
54
55
  if not line.strip() or _HEADER_RE.match(line):
55
56
  continue
57
+ if line.strip().startswith("%%"):
58
+ continue
56
59
 
57
60
  m = _TITLE_RE.match(line)
58
61
  if m:
@@ -190,6 +190,36 @@ class TestMermaidDispatch(unittest.TestCase):
190
190
  self.assertIn("Launch", joined)
191
191
  self.assertIn("●", joined)
192
192
 
193
+ def test_init_directive_does_not_leak_into_journey_output(self):
194
+ # Regression: the dispatcher must pass prelude-stripped source to
195
+ # the native journey renderer, not just use the prelude for sniffing.
196
+ src = (
197
+ '%%{init: {"theme": "dark"}}%%\n'
198
+ "journey\n"
199
+ " title T\n"
200
+ " section S\n"
201
+ " Task: 4: Me\n"
202
+ )
203
+ joined = "\n".join(mermaid.render_mermaid_lines(src, 60))
204
+ self.assertNotIn("init", joined)
205
+ self.assertNotIn("theme", joined)
206
+ self.assertNotIn("%%", joined)
207
+ self.assertIn("Task", joined)
208
+
209
+ def test_init_directive_does_not_leak_into_mindmap_output(self):
210
+ src = '%%{init: {"theme": "dark"}}%%\nmindmap\n root\n Origins\n'
211
+ joined = "\n".join(mermaid.render_mermaid_lines(src, 60))
212
+ self.assertNotIn("init", joined)
213
+ self.assertNotIn("%%", joined)
214
+ self.assertIn("Origins", joined)
215
+
216
+ def test_init_directive_does_not_leak_into_timeline_output(self):
217
+ src = '%%{init: {"theme": "dark"}}%%\ntimeline\n 2024 : Launch\n'
218
+ joined = "\n".join(mermaid.render_mermaid_lines(src, 60))
219
+ self.assertNotIn("init", joined)
220
+ self.assertNotIn("%%", joined)
221
+ self.assertIn("Launch", joined)
222
+
193
223
 
194
224
  if __name__ == "__main__":
195
225
  unittest.main()
@@ -59,6 +59,29 @@ class TestParseJourney(unittest.TestCase):
59
59
  parsed = parse_journey("journey\n title Empty Journey\n")
60
60
  self.assertEqual(parsed["sections"], [])
61
61
 
62
+ def test_init_directive_before_header_skipped(self):
63
+ parsed = parse_journey(
64
+ '%%{init: {"theme": "dark"}}%%\n'
65
+ "journey\n"
66
+ " title T\n"
67
+ " section S\n"
68
+ " Task: 4: Me\n"
69
+ )
70
+ self.assertEqual(parsed["title"], "T")
71
+ self.assertEqual(len(parsed["sections"]), 1)
72
+ self.assertEqual(parsed["sections"][0]["tasks"], [{"name": "Task", "score": 4, "actors": ["Me"]}])
73
+
74
+ def test_mid_body_comment_skipped(self):
75
+ parsed = parse_journey(
76
+ "journey\n"
77
+ " section S\n"
78
+ " Task: 4: Me\n"
79
+ " %% mid comment: 3: X\n"
80
+ " Other: 2: Me\n"
81
+ )
82
+ names = [t["name"] for t in parsed["sections"][0]["tasks"]]
83
+ self.assertEqual(names, ["Task", "Other"])
84
+
62
85
 
63
86
  class TestRenderJourney(unittest.TestCase):
64
87
 
@@ -123,6 +146,40 @@ class TestRenderJourney(unittest.TestCase):
123
146
  if ln:
124
147
  self.assertEqual(visual_len(ln), 50)
125
148
 
149
+ def test_init_directive_produces_no_fake_task_row(self):
150
+ # Regression: a leading %%{init}%% directive must not leak into the
151
+ # rendered output as a garbled task row.
152
+ src = (
153
+ '%%{init: {"theme": "dark"}}%%\n'
154
+ "journey\n"
155
+ " title T\n"
156
+ " section S\n"
157
+ " Task: 4: Me\n"
158
+ )
159
+ lines = render_journey(src, width=40)
160
+ joined = "\n".join(lines)
161
+ self.assertNotIn("init", joined)
162
+ self.assertNotIn("theme", joined)
163
+ self.assertNotIn("%%", joined)
164
+ self.assertIn("Task", joined)
165
+
166
+ def test_mid_body_comment_produces_no_fake_task_row(self):
167
+ # Regression: a %% comment line anywhere in the body must not
168
+ # render as a task with stars.
169
+ src = (
170
+ "journey\n"
171
+ " section S\n"
172
+ " Task: 4: Me\n"
173
+ " %% mid comment: 3: X\n"
174
+ " Other: 2: Me\n"
175
+ )
176
+ lines = render_journey(src, width=40)
177
+ joined = "\n".join(lines)
178
+ self.assertNotIn("comment", joined)
179
+ self.assertNotIn("%%", joined)
180
+ self.assertIn("Task", joined)
181
+ self.assertIn("Other", joined)
182
+
126
183
 
127
184
  if __name__ == "__main__":
128
185
  unittest.main()
@@ -66,6 +66,18 @@ class TestParseMindmap(unittest.TestCase):
66
66
  def test_only_header_yields_empty(self):
67
67
  self.assertEqual(parse_mindmap("mindmap\n"), "")
68
68
 
69
+ def test_indented_comment_line_dropped(self):
70
+ out = parse_mindmap("mindmap\n root\n %% a comment\n Origins\n")
71
+ self.assertNotIn("comment", out)
72
+ self.assertNotIn("%%", out)
73
+ self.assertIn("Origins", out)
74
+
75
+ def test_init_directive_before_header_dropped(self):
76
+ out = parse_mindmap('%%{init: {"theme": "dark"}}%%\nmindmap\n root\n Origins\n')
77
+ self.assertNotIn("init", out)
78
+ self.assertNotIn("%%", out)
79
+ self.assertIn("Origins", out)
80
+
69
81
 
70
82
  class TestRenderMindmap(unittest.TestCase):
71
83
 
@@ -110,6 +122,16 @@ class TestRenderMindmap(unittest.TestCase):
110
122
  if ln:
111
123
  self.assertEqual(visual_len(ln), 45)
112
124
 
125
+ def test_indented_comment_produces_no_fake_tree_node(self):
126
+ # Regression: an indented %% comment line must not render as a
127
+ # tree node.
128
+ src = "mindmap\n root\n %% a comment\n Origins\n"
129
+ lines = render_mindmap(src, width=40)
130
+ joined = "\n".join(lines)
131
+ self.assertNotIn("comment", joined)
132
+ self.assertNotIn("%%", joined)
133
+ self.assertIn("Origins", joined)
134
+
113
135
 
114
136
  if __name__ == "__main__":
115
137
  unittest.main()
@@ -53,6 +53,23 @@ class TestParseTimeline(unittest.TestCase):
53
53
  parsed = parse_timeline("timeline\n title Empty\n")
54
54
  self.assertEqual(parsed["sections"], [])
55
55
 
56
+ def test_comment_line_skipped(self):
57
+ parsed = parse_timeline(
58
+ "timeline\n 2002 : LinkedIn\n %% comment : fake\n 2004 : Facebook\n"
59
+ )
60
+ entries = parsed["sections"][0]["entries"]
61
+ self.assertEqual(
62
+ entries,
63
+ [{"date": "2002", "event": "LinkedIn"}, {"date": "2004", "event": "Facebook"}],
64
+ )
65
+
66
+ def test_init_directive_before_header_skipped(self):
67
+ parsed = parse_timeline(
68
+ '%%{init: {"theme": "dark"}}%%\ntimeline\n 2002 : LinkedIn\n'
69
+ )
70
+ entries = parsed["sections"][0]["entries"]
71
+ self.assertEqual(entries, [{"date": "2002", "event": "LinkedIn"}])
72
+
56
73
 
57
74
  class TestRenderTimeline(unittest.TestCase):
58
75
 
@@ -102,6 +119,17 @@ class TestRenderTimeline(unittest.TestCase):
102
119
  if ln:
103
120
  self.assertEqual(visual_len(ln), 50)
104
121
 
122
+ def test_comment_produces_no_fake_timeline_event(self):
123
+ # Regression: a %% comment line must not render as a timeline event.
124
+ src = "timeline\n 2002 : LinkedIn\n %% comment : fake\n 2004 : Facebook\n"
125
+ lines = render_timeline(src, width=40)
126
+ joined = "\n".join(lines)
127
+ self.assertNotIn("comment", joined)
128
+ self.assertNotIn("fake", joined)
129
+ self.assertNotIn("%%", joined)
130
+ self.assertIn("LinkedIn", joined)
131
+ self.assertIn("Facebook", joined)
132
+
105
133
 
106
134
  if __name__ == "__main__":
107
135
  unittest.main()
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