termrender 4.2.0__tar.gz → 4.3.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 (69) hide show
  1. {termrender-4.2.0 → termrender-4.3.0}/CHANGELOG.md +28 -0
  2. {termrender-4.2.0 → termrender-4.3.0}/PKG-INFO +1 -1
  3. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/CLAUDE.md +21 -3
  4. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/mermaid.py +29 -9
  5. termrender-4.3.0/src/termrender/renderers/mermaid_flow_model.py +127 -0
  6. termrender-4.3.0/src/termrender/renderers/mermaid_journey.py +121 -0
  7. termrender-4.3.0/src/termrender/renderers/mermaid_mindmap.py +99 -0
  8. termrender-4.3.0/src/termrender/renderers/mermaid_prelude.py +55 -0
  9. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/mermaid_sequence.py +8 -5
  10. termrender-4.3.0/src/termrender/renderers/mermaid_timeline.py +118 -0
  11. termrender-4.3.0/tests/test_mermaid_dispatch.py +195 -0
  12. termrender-4.3.0/tests/test_mermaid_flow_model.py +113 -0
  13. termrender-4.3.0/tests/test_mermaid_journey.py +128 -0
  14. termrender-4.3.0/tests/test_mermaid_mindmap.py +115 -0
  15. termrender-4.3.0/tests/test_mermaid_timeline.py +107 -0
  16. termrender-4.2.0/tests/test_mermaid_dispatch.py +0 -89
  17. {termrender-4.2.0 → termrender-4.3.0}/.github/workflows/publish.yml +0 -0
  18. {termrender-4.2.0 → termrender-4.3.0}/.gitignore +0 -0
  19. {termrender-4.2.0 → termrender-4.3.0}/CLAUDE.md +0 -0
  20. {termrender-4.2.0 → termrender-4.3.0}/LICENSE +0 -0
  21. {termrender-4.2.0 → termrender-4.3.0}/README.md +0 -0
  22. {termrender-4.2.0 → termrender-4.3.0}/design.json +0 -0
  23. {termrender-4.2.0 → termrender-4.3.0}/pyproject.toml +0 -0
  24. {termrender-4.2.0 → termrender-4.3.0}/requirements.json +0 -0
  25. {termrender-4.2.0 → termrender-4.3.0}/scripts/build-mermaid-ascii.sh +0 -0
  26. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/CLAUDE.md +0 -0
  27. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/__init__.py +0 -0
  28. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/__main__.py +0 -0
  29. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  30. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/_mermaid_bin.py +0 -0
  31. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/blocks.py +0 -0
  32. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/emit.py +0 -0
  33. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/layout.py +0 -0
  34. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/parser.py +0 -0
  35. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/py.typed +0 -0
  36. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/__init__.py +0 -0
  37. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/borders.py +0 -0
  38. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/charts.py +0 -0
  39. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/code.py +0 -0
  40. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/columns.py +0 -0
  41. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/diff.py +0 -0
  42. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/divider.py +0 -0
  43. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  44. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  45. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/panel.py +0 -0
  46. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/quote.py +0 -0
  47. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/stat.py +0 -0
  48. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/table.py +0 -0
  49. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/text.py +0 -0
  50. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/timeline.py +0 -0
  51. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/renderers/tree.py +0 -0
  52. {termrender-4.2.0 → termrender-4.3.0}/src/termrender/style.py +0 -0
  53. {termrender-4.2.0 → termrender-4.3.0}/tests/__init__.py +0 -0
  54. {termrender-4.2.0 → termrender-4.3.0}/tests/test_charts.py +0 -0
  55. {termrender-4.2.0 → termrender-4.3.0}/tests/test_cli_contract.py +0 -0
  56. {termrender-4.2.0 → termrender-4.3.0}/tests/test_column_alignment.py +0 -0
  57. {termrender-4.2.0 → termrender-4.3.0}/tests/test_diff.py +0 -0
  58. {termrender-4.2.0 → termrender-4.3.0}/tests/test_inline_badge.py +0 -0
  59. {termrender-4.2.0 → termrender-4.3.0}/tests/test_linebreak.py +0 -0
  60. {termrender-4.2.0 → termrender-4.3.0}/tests/test_mermaid_compat.py +0 -0
  61. {termrender-4.2.0 → termrender-4.3.0}/tests/test_mermaid_gantt.py +0 -0
  62. {termrender-4.2.0 → termrender-4.3.0}/tests/test_mermaid_pie.py +0 -0
  63. {termrender-4.2.0 → termrender-4.3.0}/tests/test_mermaid_sequence.py +0 -0
  64. {termrender-4.2.0 → termrender-4.3.0}/tests/test_myst_gaps.py +0 -0
  65. {termrender-4.2.0 → termrender-4.3.0}/tests/test_stat.py +0 -0
  66. {termrender-4.2.0 → termrender-4.3.0}/tests/test_tasklist.py +0 -0
  67. {termrender-4.2.0 → termrender-4.3.0}/tests/test_timeline.py +0 -0
  68. {termrender-4.2.0 → termrender-4.3.0}/tests/test_variable_colons.py +0 -0
  69. {termrender-4.2.0 → termrender-4.3.0}/uv.lock +0 -0
@@ -1,6 +1,34 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.3.0 (2026-07-06)
5
+
6
+ ### Features
7
+
8
+ - Add flowchart renderer data model
9
+ ([`cf3caef`](https://github.com/crouton-labs/termrender/commit/cf3caef28411d2ace3f71c63f218af52ba460da0))
10
+
11
+ - Wire sequence renderer and add native mindmap/journey/timeline renderers
12
+ ([`120b51c`](https://github.com/crouton-labs/termrender/commit/120b51cddf58cd91ee948706c143c84ca42c7e9f))
13
+
14
+ - mermaid.py dispatch now routes sequenceDiagram to the finished mermaid_sequence.render_sequence,
15
+ and mindmap/journey/timeline to new native renderers, leaving flowchart/graph/class/state/ER on
16
+ the vendored Go binary path unchanged. - New shared mermaid_prelude.strip_prelude_lines skips
17
+ leading %% comments, %%{init}%% directives, and --- YAML frontmatter before sniffing a diagram's
18
+ type keyword, fixing valid diagrams with those preludes mis-routing to the binary fallback.
19
+ mermaid_sequence.py's own header guard uses the same helper. - mermaid_mindmap.py:
20
+ indentation-based mindmap source maps directly onto tree.py's shape; strips the mindmap header,
21
+ ::icon()/:::class decoration, and node-shape delimiters down to label text. - mermaid_journey.py:
22
+ section/task outline mapped onto tree.py, tasks rendered as depth-1 children with a star rating
23
+ and actor list. - mermaid_timeline.py: sections rendered as separate timeline.py sub-timelines,
24
+ with period-less continuation lines correctly attaching to the previous row instead of repeating
25
+ the date. - Dispatch integration tests for every new route plus all three prelude forms; parse +
26
+ golden-output render tests per new module. - CLAUDE.md updated to describe the current
27
+ dispatch/prelude state.
28
+
29
+ Full suite: 230 -> 292 tests, all green.
30
+
31
+
4
32
  ## v4.2.0 (2026-07-06)
5
33
 
6
34
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.2.0
3
+ Version: 4.3.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
@@ -43,11 +43,13 @@ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by
43
43
 
44
44
  `mermaid.py`'s `render(block, color)` (leaf signature, no `render_child` — the parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer) is a thin wrapper: it reads `block.attrs["_rendered"]` if layout already precomputed it, else calls `render_mermaid_lines(source, width)`, then pads every line to `block.width` with `visual_ljust`. All the type-routing logic lives in `render_mermaid_lines`, shared with `layout.py`'s height pass (see below) so there is exactly one dispatch path, not two.
45
45
 
46
- **Dispatch is by first non-blank line, case-insensitive** (`_first_line_type`): `pie` and `gantt` route to the native Python renderers in `mermaid_pie.py` / `mermaid_gantt.py`. Every other type — `graph`/`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram`, `erDiagram`, `journey`, `mindmap`, … — routes to `_render_via_binary`, the pre-existing single path through the vendored Go `mermaid-ascii` binary. **Only `pie` and `gantt` have native renderers; every other type's degradation is byte-for-byte what it was before this dispatcher existed** — do not add a bordered fallback panel for them, since crouter's attach viewer detects render failure by the *absence* of box-drawing glyphs in the output, and a decorative border would defeat that.
46
+ **Dispatch is by first real line, case-insensitive** (`_first_line_type`), after skipping any prelude via `mermaid_prelude.strip_prelude_lines` (see below): `pie`, `gantt`, `sequenceDiagram`, `mindmap`, `journey`, and `timeline` route to native Python renderers (`mermaid_pie.py`, `mermaid_gantt.py`, `mermaid_sequence.py`, `mermaid_mindmap.py`, `mermaid_journey.py`, `mermaid_timeline.py`). Every other type — `graph`/`flowchart`, `classDiagram`, `stateDiagram`, `erDiagram`, … — routes to `_render_via_binary`, the pre-existing single path through the vendored Go `mermaid-ascii` binary. **Only the six listed types have native renderers; every other type's degradation is byte-for-byte what it was before this dispatcher existed** — do not add a bordered fallback panel for them, since crouter's attach viewer detects render failure by the *absence* of box-drawing glyphs in the output, and a decorative border would defeat that.
47
47
 
48
- **`_render_via_binary`** (used for flowchart/graph/sequence/everything-without-a-native-renderer): normalizes via `preprocess_mermaid_for_ascii`, shells out with `check=True`, and undoes the Latin-1 double-encoding via `fix_mermaid_encoding` (`mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters e.g. `→` becomes `â\x86\x92`; `text.encode("latin-1").decode("utf-8")` reverses it, and on failure silently returns the corrupted string). Any failure `CalledProcessError` (including a non-zero exit, now actually checked via `check=True`), missing binary, or timeout degrades to the raw source text. A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on both the PyPI 1.2.0 wheel and vendored master; the resulting non-zero exit degrades the same way.
48
+ **`mermaid_prelude.py`** a tiny shared module (no dependency on `Block` or the rest of the pipeline) implementing `strip_prelude_lines(lines)`, used by both `mermaid.py`'s dispatch sniff and `mermaid_sequence.py`'s own header check. It skips leading blank lines, `%%` line comments, `%%{init: ...}%%` directives, and `---`-delimited YAML frontmatter, permissively: an unterminated frontmatter block or directive is left alone rather than eating real content. Prelude stripping is for *sniffing/validating the type line only* — the per-type body parsers still receive the full untouched source; `mermaid_sequence.py`'s `_parse` already tolerates arbitrary prelude lines before its header (they fail every line pattern and are skipped via its `seen_header` gate), and `mermaid_pie.py`/`mermaid_gantt.py`/the mindmap/journey/timeline parsers likewise just skip lines that don't match their grammar a frontmatter `title: X` line could in principle collide with a `title` grammar rule, but this is accepted as the same class of pragmatic-parsing tradeoff as any other unrecognized line, not something the prelude skip needs to prevent.
49
49
 
50
- **`visual_ljust` pads but never truncates**: every output line (from any type binary or native) is padded to `block.width` in the outer `render()`. Lines wider than `block.width` overflow without clipping. `mermaid-ascii` has no width-control flag (`--maxWidth`/`-w` do not exist — the call passes only `-f - -y 1`), so wide flowchart/sequence output silently exceeds the layout boundary; the native pie/gantt renderers respect `width` by construction (they size their own bars to it) and don't have this problem.
50
+ **`_render_via_binary`** (used for flowchart/graph/class/state/ER/everything-without-a-native-renderer): normalizes via `preprocess_mermaid_for_ascii`, shells out with `check=True`, and undoes the Latin-1 double-encoding via `fix_mermaid_encoding` (`mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters — e.g. `→` becomes `â\x86\x92`; `text.encode("latin-1").decode("utf-8")` reverses it, and on failure silently returns the corrupted string). Any failure — `CalledProcessError` (including a non-zero exit, now actually checked via `check=True`), missing binary, or timeout degrades to the raw source text. A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on both the PyPI 1.2.0 wheel and vendored master; the resulting non-zero exit degrades the same way. Note `preprocess_mermaid_for_ascii` has its own independent first-line sniff (not `mermaid_prelude`-aware) to decide sequence-vs-flowchart-vs-other normalization — a prelude ahead of `graph`/`flowchart`/`sequenceDiagram` here means no shape/arrow normalization runs, an existing (unfixed) limitation of the binary path, orthogonal to dispatch routing.
51
+
52
+ **`visual_ljust` pads but never truncates**: every output line (from any type — binary or native) is padded to `block.width` in the outer `render()`. Lines wider than `block.width` overflow without clipping. `mermaid-ascii` has no width-control flag (`--maxWidth`/`-w` do not exist — the call passes only `-f - -y 1`), so wide flowchart output silently exceeds the layout boundary; the native renderers respect `width` by construction (pie/gantt/timeline size their own bars to it; sequence/mindmap/journey have intrinsic width and may overflow, matching the binary path's contract) — see each renderer's own note below.
51
53
 
52
54
  Layout (`layout.py::resolve_height`, `BlockType.MERMAID` branch) precomputes `block.attrs["_rendered"]` by calling the same `render_mermaid_lines`, so `mermaid.py`'s `render()` only recomputes when that key is absent (e.g. calling `render()` on a block that skipped the layout pass).
53
55
 
@@ -66,3 +68,19 @@ A text pie chart is worse than a labeled bar chart at the same information densi
66
68
  **Missing start dates fall back to `last_end`**, a running cursor updated after every successfully-placed task (mirroring mermaid's own default-start-follows-previous-task semantics): a bare `Task2 :5d` with no date and no `after` picks up where the previous task left off; an `after <id>` referencing an id that was never seen falls back the same way rather than dropping the task. A task with no start anchor at all (first task in the diagram, no date, no `after`) is skipped — degrade, don't crash.
67
69
 
68
70
  `render` scales every task's `(start, end)` into a ratio over the full `[min_start, max_end]` span and draws a `░`/`█` track via `_draw_span`; section names are their own row, un-indented, with all task rows below sharing one global label-column width for alignment across sections. Diagrams with zero resolved tasks (parse produced no sections) degrade to the raw source lines.
71
+
72
+ ## `mermaid_sequence.py` — deterministic column layout, no graph-layout search
73
+
74
+ The one native renderer not built by reusing another primitive (there's no existing "sequence diagram" shape in termrender to reuse) — `render_sequence(source, width)` is a standalone module with its own parser (`_parse`, producing `Arrow`/`NoteEvent`/`BlockBoundary`/`PlainLabel` events) and its own column-layout pass (`_layout_columns`, a 1D constraint solve: message order already fixes the vertical axis, declaration order fixes the column axis, so gaps between adjacent columns are widened only enough for whatever message text crosses them). `width` is advisory-only and never wraps/truncates — sequence diagrams have intrinsic width and may overflow, same contract as the flowchart binary path. Parsing never raises except when the source isn't a sequence diagram at all (checked via `mermaid_prelude.strip_prelude_lines` against the first real line); every malformed/unrecognized construct within a real sequence diagram (a stray `else`/`end`, an unsupported arrow punctuation, a flowchart-only `-.->`) degrades to a plain text line instead. See the module's own docstring for the full grammar supported and known degradations (CJK column misalignment, `Note` multi-participant simplification, etc.).
75
+
76
+ ## `mermaid_mindmap.py` — mindmap source is already `tree.py`'s shape
77
+
78
+ Mindmap is indentation-based exactly like `tree.py`'s input format, so `parse_mindmap` does no layout of its own: it drops the `mindmap` header line and `::icon(...)`/`:::className` decoration lines, strips each node's shape delimiters (`(())`, `{{}}`, `))((`, `)(`, `[]`, `()`, checked most-specific-first the same way `mermaid.py`'s `_FLOWCHART_SHAPE_SUBS` is ordered) down to label text, and preserves each surviving line's original leading whitespace verbatim — depth then falls out of `tree.py`'s own indentation auto-detection for free. `render` hands the transformed text to `tree.py::render` via a synthetic `Block(type=BlockType.TREE, ...)`, always `color=False`. A diagram with nothing left after stripping (blank, or header-only) degrades to the raw source lines.
79
+
80
+ ## `mermaid_journey.py` — section/task outline mapped onto `tree.py`
81
+
82
+ A journey is a two-level outline (`section` → tasks), so `parse_journey` splits `name: score: actors` task lines (tolerating a missing/non-numeric score as `None` and missing actors as `[]`, never raising) into section buckets — a task before any `section` line lands in a leading unnamed bucket, matching `mermaid_gantt.py`'s same convention. `render` builds one synthetic indented-tree source string (section names at depth 0, `_format_task` output — `name ★★★☆☆ (actors)`, stars clamped to the 0–5 range — as depth-1 children under a two-space indent) and renders it through `tree.py::render` via a synthetic `Block`, same pattern as mindmap. The diagram title (if any) is a plain line prepended above the tree, not part of it. Sections with no tasks are dropped; zero surviving sections degrades to the raw source lines.
83
+
84
+ ## `mermaid_timeline.py` — sections rendered as separate `timeline.py` sub-timelines
85
+
86
+ `parse_timeline` handles `title`, `section`, and `period : event[ : event ...]` lines; a period-less line (blank before the first `:`) continues the *previous* row's period rather than introducing a new one — tracked via a `shows_date` flag so only the row that actually introduces a period (same-line or most recent prior line) prints it, everything else in that group prints a blank date column for alignment. `render` calls `timeline.py::render` once per section (each section's `entries` in a synthetic `Block(type=BlockType.TIMELINE, ...)`), inserting the section name as its own plain row above each sub-timeline and a blank separator row between sections — reuses the existing bullet/connector rendering rather than a bespoke one. A diagram with zero resolved entries degrades to the raw source lines.
@@ -7,7 +7,15 @@ import subprocess
7
7
 
8
8
  from termrender._mermaid_bin import mermaid_ascii_bin
9
9
  from termrender.blocks import Block
10
- from termrender.renderers import mermaid_gantt, mermaid_pie
10
+ from termrender.renderers import (
11
+ mermaid_gantt,
12
+ mermaid_journey,
13
+ mermaid_mindmap,
14
+ mermaid_pie,
15
+ mermaid_sequence,
16
+ mermaid_timeline,
17
+ )
18
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
11
19
  from termrender.style import visual_ljust
12
20
 
13
21
 
@@ -118,13 +126,16 @@ def preprocess_mermaid_for_ascii(source: str) -> str:
118
126
 
119
127
 
120
128
  def _first_line_type(source: str) -> str:
121
- """Return the lowercased first non-blank line's leading keyword.
129
+ """Return the lowercased first real line's leading keyword.
122
130
 
123
131
  This is the dispatch key: mermaid identifies a diagram's type from its
124
132
  first line (``graph``/``flowchart``, ``pie``, ``gantt``, ``sequenceDiagram``,
125
- ``classDiagram``, …). Returns ``""`` for an empty/blank source.
133
+ ``classDiagram``, …), after skipping any prelude (blank lines, ``%%``
134
+ comments/directives, ``---`` YAML frontmatter — see ``mermaid_prelude.py``).
135
+ Returns ``""`` for a source with no real content past its prelude.
126
136
  """
127
- first = next((l.strip() for l in source.splitlines() if l.strip()), "")
137
+ lines = strip_prelude_lines(source.splitlines())
138
+ first = next((l.strip() for l in lines if l.strip()), "")
128
139
  return first.lower()
129
140
 
130
141
 
@@ -156,17 +167,26 @@ def _render_via_binary(source: str) -> str:
156
167
  def render_mermaid_lines(source: str, width: int) -> list[str]:
157
168
  """Dispatch a mermaid source to its type's renderer and return raw lines.
158
169
 
159
- ``pie`` and ``gantt`` get dedicated native Python renderers. Every other
160
- type (flowchart/graph, sequence, class, state, ER, journey, mindmap, …)
161
- keeps today's exact path: the vendored Go mermaid-ascii binary, which
162
- echoes unparseable/unsupported source back verbatim. Lines are returned
163
- unpadded; callers apply width padding uniformly.
170
+ ``pie``, ``gantt``, ``sequenceDiagram``, ``mindmap``, ``journey``, and
171
+ ``timeline`` get dedicated native Python renderers. Every other type
172
+ (flowchart/graph, class, state, ER, …) keeps today's exact path: the
173
+ vendored Go mermaid-ascii binary, which echoes unparseable/unsupported
174
+ source back verbatim. Lines are returned unpadded; callers apply width
175
+ padding uniformly.
164
176
  """
165
177
  diagram_type = _first_line_type(source)
166
178
  if diagram_type.startswith("pie"):
167
179
  return mermaid_pie.render(source, width)
168
180
  if diagram_type.startswith("gantt"):
169
181
  return mermaid_gantt.render(source, width)
182
+ if diagram_type.startswith("sequencediagram"):
183
+ return mermaid_sequence.render_sequence(source, width)
184
+ if diagram_type.startswith("mindmap"):
185
+ return mermaid_mindmap.render(source, width)
186
+ if diagram_type.startswith("journey"):
187
+ return mermaid_journey.render(source, width)
188
+ if diagram_type.startswith("timeline"):
189
+ return mermaid_timeline.render(source, width)
170
190
  return _render_via_binary(source).split("\n")
171
191
 
172
192
 
@@ -0,0 +1,127 @@
1
+ """Data model for the native mermaid flowchart/graph renderer.
2
+
3
+ Pure dataclasses + enums, zero logic — the contract between the parser
4
+ (``mermaid_flow_parser.py``, source → :class:`FlowGraph`) and the layout
5
+ engine (``mermaid_flow_layout.py``, :class:`FlowGraph` → char grid). Neither
6
+ side imports the other; both import this module. Nothing here has behavior.
7
+
8
+ Model shape
9
+ -----------
10
+ A :class:`FlowGraph` carries a :class:`Direction`, a declaration-ordered
11
+ list of unique :class:`FlowNode` (id is the graph key, label is the drawn
12
+ text), a declaration-ordered list of :class:`FlowEdge` with ``&`` fan-out
13
+ already expanded to individual edges, and the top-level :class:`Subgraph`
14
+ blocks (nesting via ``Subgraph.children``). Arrowheads are two per-endpoint
15
+ booleans (``dst_arrow``/``src_arrow``) independent of the line
16
+ :class:`EdgeStyle`. Ignored source directives (class/classDef/style/click/
17
+ linkStyle) leave no trace in the model — the parser drops them.
18
+
19
+ See ``flow-design.md`` (this node's orchestrator context dir) for the full
20
+ architecture and the decisions baked into these shapes.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from enum import Enum
27
+
28
+ __all__ = [
29
+ "Direction",
30
+ "NodeShape",
31
+ "EdgeStyle",
32
+ "FlowNode",
33
+ "FlowEdge",
34
+ "Subgraph",
35
+ "FlowGraph",
36
+ ]
37
+
38
+
39
+ class Direction(Enum):
40
+ """Rank-flow direction. ``TD`` is a mermaid alias for ``TB`` and is
41
+ normalized to ``TB`` by the parser (never stored as a separate member)."""
42
+
43
+ TB = "TB" # top → bottom
44
+ LR = "LR" # left → right
45
+ RL = "RL" # right → left
46
+ BT = "BT" # bottom → top
47
+
48
+
49
+ class NodeShape(Enum):
50
+ """Declared node shape. The engine renders ``DIAMOND``, ``ROUND``/
51
+ ``STADIUM`` (rounded corners) and ``CIRCLE`` distinctly where cheap; every
52
+ other shape falls back to a rectangle border. Only the shape class + label
53
+ text are load-bearing."""
54
+
55
+ RECT = "rect" # [text]
56
+ ROUND = "round" # (text)
57
+ STADIUM = "stadium" # ([text])
58
+ SUBROUTINE = "subroutine" # [[text]]
59
+ CYLINDER = "cylinder" # [(text)]
60
+ CIRCLE = "circle" # ((text))
61
+ DIAMOND = "diamond" # {text}
62
+ HEXAGON = "hexagon" # {{text}}
63
+ PARALLELOGRAM = "parallelogram" # [/text/]
64
+
65
+
66
+ class EdgeStyle(Enum):
67
+ """Edge line style. Arrowheads are orthogonal to style (see
68
+ :class:`FlowEdge`)."""
69
+
70
+ SOLID = "solid" # --- / -->
71
+ THICK = "thick" # === / ==>
72
+ DOTTED = "dotted" # -.- / -.->
73
+
74
+
75
+ @dataclass
76
+ class FlowNode:
77
+ """A graph node. ``id`` is the graph key (dedup + edge endpoints);
78
+ ``label`` is the drawn text. Bare ``A`` yields ``id == label == "A"``.
79
+ Re-declaring an id keeps the first-seen node; a later declaration that
80
+ carries a shape/label updates it in place (parser policy)."""
81
+
82
+ id: str
83
+ label: str
84
+ shape: NodeShape = NodeShape.RECT
85
+
86
+
87
+ @dataclass
88
+ class FlowEdge:
89
+ """A directed edge between node ids. ``dst_arrow``/``src_arrow`` are the
90
+ drawn arrowheads: ``-->`` → dst_arrow=True, src_arrow=False; ``---`` → both
91
+ False (plain line); ``<-->`` → both True. ``label`` is the optional edge
92
+ label (``|text|`` or the ``-- text -->`` inline form); ``None`` when absent
93
+ (empty-string labels are stored as ``None``)."""
94
+
95
+ src: str
96
+ dst: str
97
+ style: EdgeStyle = EdgeStyle.SOLID
98
+ label: str | None = None
99
+ dst_arrow: bool = True
100
+ src_arrow: bool = False
101
+
102
+
103
+ @dataclass
104
+ class Subgraph:
105
+ """A ``subgraph id [title] ... end`` block. ``node_ids`` are the ids
106
+ declared directly inside this block (NOT those inside nested children).
107
+ ``children`` are nested subgraphs. ``title`` is the display title (defaults
108
+ to id when the block has no explicit title)."""
109
+
110
+ id: str
111
+ title: str
112
+ node_ids: list[str] = field(default_factory=list)
113
+ children: list["Subgraph"] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class FlowGraph:
118
+ """Parsed flowchart. ``nodes`` is in first-seen declaration order, unique
119
+ by id. ``edges`` is in declaration order, with ``&`` fan-out already
120
+ expanded to individual edges. ``subgraphs`` are the top-level blocks
121
+ (nesting via ``Subgraph.children``). Ignored lines (class/classDef/style/
122
+ click/linkStyle) leave no trace."""
123
+
124
+ direction: Direction
125
+ nodes: list[FlowNode] = field(default_factory=list)
126
+ edges: list[FlowEdge] = field(default_factory=list)
127
+ subgraphs: list[Subgraph] = field(default_factory=list)
@@ -0,0 +1,121 @@
1
+ """Native renderer for mermaid ``journey`` diagrams.
2
+
3
+ Mermaid journey grammar (subset)::
4
+
5
+ journey
6
+ title My working day
7
+ section Go to work
8
+ Make tea: 5: Me
9
+ Go upstairs: 3: Me
10
+ Do work: 1: Me, Cat
11
+ section Go home
12
+ Go downstairs: 5: Me
13
+
14
+ Each section is a group of tasks; each task is ``name: score: actors``
15
+ (``actors`` a comma-separated list, ``score`` a 1-5 satisfaction rating).
16
+ A journey is a two-level outline (section -> tasks) — the same shape
17
+ ``tree.py`` renders — so sections become depth-0 tree labels and tasks
18
+ become their depth-1 children, reusing ``tree.py``'s guide-line rendering
19
+ via a synthetic ``Block`` rather than duplicating it (mirrors how
20
+ ``mermaid_pie.py`` reuses ``charts.py``'s ``render_bar``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+
27
+ from termrender.blocks import Block, BlockType
28
+ from termrender.renderers.tree import render as render_tree
29
+ from termrender.style import visual_ljust
30
+
31
+ _HEADER_RE = re.compile(r"^\s*journey\b", re.IGNORECASE)
32
+ _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
33
+ _SECTION_RE = re.compile(r"^\s*section\s+(.*\S)\s*$", re.IGNORECASE)
34
+
35
+
36
+ def parse_journey(source: str) -> dict:
37
+ """Parse mermaid journey syntax into a title + section/task tree.
38
+
39
+ Task lines with fewer than two ``:``-separated fields (no score) are
40
+ still captured with ``score=None``; a non-numeric score is likewise
41
+ tolerated (``score=None``) rather than raising. A task line before any
42
+ ``section`` header lands in an unnamed leading section. Sections with
43
+ no tasks are dropped.
44
+
45
+ Returns ``{"title": str | None, "sections": [{"name": str | None,
46
+ "tasks": [{"name": str, "score": int | None, "actors": list[str]}]}]}``.
47
+ """
48
+ title: str | None = None
49
+ sections: list[dict] = [{"name": None, "tasks": []}]
50
+
51
+ for line in source.splitlines():
52
+ if not line.strip() or _HEADER_RE.match(line):
53
+ continue
54
+
55
+ m = _TITLE_RE.match(line)
56
+ if m:
57
+ title = m.group(1)
58
+ continue
59
+
60
+ m = _SECTION_RE.match(line)
61
+ if m:
62
+ sections.append({"name": m.group(1), "tasks": []})
63
+ continue
64
+
65
+ if ":" not in line:
66
+ continue
67
+ parts = line.split(":", 2)
68
+ name = parts[0].strip()
69
+ if not name:
70
+ continue
71
+ score: int | None = None
72
+ actors: list[str] = []
73
+ if len(parts) >= 2:
74
+ score_str = parts[1].strip()
75
+ if score_str.lstrip("-").isdigit():
76
+ score = int(score_str)
77
+ if len(parts) >= 3:
78
+ actors = [a.strip() for a in parts[2].split(",") if a.strip()]
79
+ sections[-1]["tasks"].append({"name": name, "score": score, "actors": actors})
80
+
81
+ sections = [s for s in sections if s["tasks"]]
82
+ return {"title": title, "sections": sections}
83
+
84
+
85
+ def _format_task(task: dict) -> str:
86
+ """Render one task as ``name \u2605\u2605\u2605\u2606\u2606 (actor, actor)``."""
87
+ label = task["name"]
88
+ if task["score"] is not None:
89
+ filled = max(0, min(5, task["score"]))
90
+ stars = "\u2605" * filled + "\u2606" * (5 - filled)
91
+ label = f"{label} {stars}"
92
+ if task["actors"]:
93
+ label = f"{label} ({', '.join(task['actors'])})"
94
+ return label
95
+
96
+
97
+ def render(source: str, width: int) -> list[str]:
98
+ """Render mermaid journey syntax as a section/task guide-line tree.
99
+
100
+ A diagram with no parseable tasks degrades to the raw source text.
101
+ """
102
+ parsed = parse_journey(source)
103
+ sections = parsed["sections"]
104
+ if not sections:
105
+ return source.splitlines() or [""]
106
+
107
+ lines: list[str] = []
108
+ if parsed["title"]:
109
+ lines.append(visual_ljust(parsed["title"], width))
110
+
111
+ tree_lines: list[str] = []
112
+ for section in sections:
113
+ if section["name"]:
114
+ tree_lines.append(section["name"])
115
+ tree_lines.extend(f" {_format_task(t)}" for t in section["tasks"])
116
+ else:
117
+ tree_lines.extend(_format_task(t) for t in section["tasks"])
118
+
119
+ block = Block(type=BlockType.TREE, attrs={"source": "\n".join(tree_lines)}, width=width)
120
+ lines.extend(render_tree(block, color=False))
121
+ return lines
@@ -0,0 +1,99 @@
1
+ """Native renderer for mermaid ``mindmap`` diagrams.
2
+
3
+ Mermaid mindmap grammar (subset)::
4
+
5
+ mindmap
6
+ root((mindmap))
7
+ Origins
8
+ Long history
9
+ ::icon(fa fa-book)
10
+ Research
11
+ On effectiveness<br/>and features
12
+ Tools
13
+ Pen and paper
14
+
15
+ Mindmap source is *already* an indentation-based tree — the same shape
16
+ ``tree.py`` renders — so this module does no layout of its own: it strips
17
+ mermaid-only decoration (the ``mindmap`` header, node-shape delimiters,
18
+ ``::icon(...)`` and ``:::className`` lines) down to plain indented labels
19
+ and hands the result to ``tree.py`` via a synthetic ``Block``, reusing its
20
+ guide-line rendering rather than duplicating it (mirrors how
21
+ ``mermaid_pie.py`` reuses ``charts.py``'s ``render_bar``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ from termrender.blocks import Block, BlockType
29
+ from termrender.renderers.tree import render as render_tree
30
+
31
+ _HEADER_RE = re.compile(r"^\s*mindmap\b", re.IGNORECASE)
32
+ _ICON_RE = re.compile(r"^::icon\(", re.IGNORECASE)
33
+ _CLASS_RE = re.compile(r"^:::")
34
+ _BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
35
+
36
+ # Node-shape wrappers, most-specific delimiter pair first so a greedy
37
+ # shorter pattern doesn't bite off half of a longer one (mirrors the
38
+ # ordering discipline in mermaid.py's ``_FLOWCHART_SHAPE_SUBS``). The
39
+ # leading ``\w*`` is an optional node id, discarded — only ever the label
40
+ # text is displayed.
41
+ _NODE_SHAPE_RES: list[re.Pattern[str]] = [
42
+ re.compile(r"^(\w*)\)\)(.+)\(\($"), # bang: id))text((
43
+ re.compile(r"^(\w*)\(\((.+)\)\)$"), # circle: id((text))
44
+ re.compile(r"^(\w*)\{\{(.+)\}\}$"), # hexagon: id{{text}}
45
+ re.compile(r"^(\w*)\[(.+)\]$"), # square: id[text]
46
+ re.compile(r"^(\w*)\((.+)\)$"), # rounded: id(text)
47
+ re.compile(r"^(\w*)\)(.+)\($"), # cloud: id)text(
48
+ ]
49
+
50
+
51
+ def _node_label(text: str) -> str:
52
+ """Strip a mindmap node's shape delimiters, keeping only its label.
53
+
54
+ A bare word with no shape (the common case) matches nothing and is
55
+ returned unchanged.
56
+ """
57
+ for pattern in _NODE_SHAPE_RES:
58
+ m = pattern.match(text)
59
+ if m:
60
+ return _BR_RE.sub(" / ", m.group(2).strip())
61
+ return _BR_RE.sub(" / ", text)
62
+
63
+
64
+ def parse_mindmap(source: str) -> str:
65
+ """Parse mermaid mindmap syntax into ``tree.py``'s plain indented format.
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.
73
+ """
74
+ out_lines: list[str] = []
75
+ for raw_line in source.splitlines():
76
+ if not raw_line.strip():
77
+ continue
78
+ if _HEADER_RE.match(raw_line):
79
+ continue
80
+ stripped = raw_line.strip()
81
+ if _ICON_RE.match(stripped) or _CLASS_RE.match(stripped):
82
+ continue
83
+ indent = raw_line[: len(raw_line) - len(raw_line.lstrip(" "))]
84
+ out_lines.append(f"{indent}{_node_label(stripped)}")
85
+ return "\n".join(out_lines)
86
+
87
+
88
+ def render(source: str, width: int) -> list[str]:
89
+ """Render mermaid mindmap syntax as a guide-line tree.
90
+
91
+ A diagram with no survivable content (only a header, or blank)
92
+ degrades to the raw source text.
93
+ """
94
+ tree_source = parse_mindmap(source)
95
+ if not tree_source.strip():
96
+ return source.splitlines() or [""]
97
+
98
+ block = Block(type=BlockType.TREE, attrs={"source": tree_source}, width=width)
99
+ return render_tree(block, color=False)
@@ -0,0 +1,55 @@
1
+ """Shared handling for mermaid's optional prelude before the type keyword.
2
+
3
+ Mermaid tolerates three forms of preamble before a diagram's type line
4
+ (``pie``, ``gantt``, ``sequenceDiagram``, …): ``%%`` line comments,
5
+ ``%%{init: ...}%%`` directives (mermaid's config-block syntax), and
6
+ ``---``-delimited YAML frontmatter (used for e.g. a top-level ``title:``
7
+ key). :func:`strip_prelude_lines` skips all three so callers that sniff or
8
+ validate "what type is this diagram" see the real type line, not a
9
+ directive or frontmatter key.
10
+
11
+ Used by ``mermaid.py`` (dispatch sniffing) and ``mermaid_sequence.py`` (its
12
+ own "is this actually a sequence diagram" header check) — kept here rather
13
+ than in either so neither has to import the other's private helpers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+
19
+ def strip_prelude_lines(lines: list[str]) -> list[str]:
20
+ """Return ``lines`` with leading blank/comment/directive/frontmatter lines dropped.
21
+
22
+ Skipping is permissive: an unterminated frontmatter block (opening
23
+ ``---`` with no closing ``---``) or an unterminated ``%%{`` directive
24
+ (never reaches a line ending in ``%%``) is left untouched rather than
25
+ consumed — better to sniff a directive/frontmatter line as the "type"
26
+ than to silently discard real content.
27
+ """
28
+ i = 0
29
+ n = len(lines)
30
+ while i < n:
31
+ stripped = lines[i].strip()
32
+ if not stripped:
33
+ i += 1
34
+ continue
35
+ if stripped == "---":
36
+ j = i + 1
37
+ while j < n and lines[j].strip() != "---":
38
+ j += 1
39
+ if j >= n:
40
+ break # unterminated frontmatter
41
+ i = j + 1
42
+ continue
43
+ if stripped.startswith("%%"):
44
+ if stripped.startswith("%%{") and not stripped.endswith("%%"):
45
+ j = i + 1
46
+ while j < n and not lines[j].strip().endswith("%%"):
47
+ j += 1
48
+ if j >= n:
49
+ break # unterminated directive
50
+ i = j + 1
51
+ continue
52
+ i += 1
53
+ continue
54
+ break
55
+ return lines[i:]
@@ -1,9 +1,10 @@
1
1
  """Native ASCII renderer for mermaid ``sequenceDiagram`` sources.
2
2
 
3
- Standalone module: exposes a single pure function, ``render_sequence``.
4
- Not wired into the ``mermaid.py`` dispatcher yet (a later integration
5
- phase owns that); this module has no dependency on ``Block`` or the
6
- rest of the renderer pipeline, and nothing here is imported elsewhere.
3
+ Standalone module: exposes a single pure function, ``render_sequence``,
4
+ wired into ``mermaid.py``'s dispatcher for any source whose first
5
+ non-blank (post-prelude) line starts with ``sequenceDiagram``. This
6
+ module has no dependency on ``Block`` or the rest of the renderer
7
+ pipeline beyond the shared prelude-skipping helper.
7
8
 
8
9
  Layout model
9
10
  ------------
@@ -57,6 +58,7 @@ from __future__ import annotations
57
58
  import re
58
59
  from dataclasses import dataclass
59
60
 
61
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
60
62
  from termrender.style import visual_center, visual_len
61
63
 
62
64
  __all__ = ["render_sequence", "SequenceDiagramError"]
@@ -567,7 +569,8 @@ def render_sequence(source: str, width: int) -> list[str]:
567
569
  del width # advisory only; diagrams have intrinsic width (see docstring)
568
570
 
569
571
  lines = source.splitlines()
570
- first = next((line.strip() for line in lines if line.strip()), "")
572
+ sniff_lines = strip_prelude_lines(lines)
573
+ first = next((line.strip() for line in sniff_lines if line.strip()), "")
571
574
  if not first.lower().startswith("sequencediagram"):
572
575
  raise SequenceDiagramError(
573
576
  "not a mermaid sequence diagram: source must start with 'sequenceDiagram'"