termrender 4.7.0__tar.gz → 4.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termrender-4.7.0 → termrender-4.8.0}/CHANGELOG.md +36 -0
- {termrender-4.7.0 → termrender-4.8.0}/PKG-INFO +1 -1
- termrender-4.8.0/src/termrender/renderers/mermaid_state.py +519 -0
- termrender-4.8.0/tests/test_mermaid_state.py +486 -0
- {termrender-4.7.0 → termrender-4.8.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/.gitignore +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/LICENSE +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/README.md +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/design.json +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/pyproject.toml +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/requirements.json +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/__main__.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/blocks.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/emit.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/layout.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/parser.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/py.typed +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_class.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/src/termrender/style.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/__init__.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_charts.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_diff.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_linebreak.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_class.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_corpus.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_stat.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_tasklist.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_timeline.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.7.0 → termrender-4.8.0}/uv.lock +0 -0
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.8.0 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- Native mermaid stateDiagram renderer on the flowchart engine
|
|
9
|
+
([`ff4fa87`](https://github.com/crouton-labs/termrender/commit/ff4fa87d275d0e18f64e386c16e13e237ad99923))
|
|
10
|
+
|
|
11
|
+
Adds src/termrender/renderers/mermaid_state.py exposing render_state(source, width) -> list[str] as
|
|
12
|
+
a thin adapter: parses stateDiagram/stateDiagram-v2 grammar into the existing flowchart engine's
|
|
13
|
+
own FlowGraph/FlowNode/FlowEdge/Subgraph model and renders through layout_flowgraph directly
|
|
14
|
+
(grandalf layout, box rasterizer, orthogonal router, subgraph frames), reusing that engine's
|
|
15
|
+
degradation contract unchanged.
|
|
16
|
+
|
|
17
|
+
Covers: A --> B / A --> B : event transitions; [*] as per-scope start/end pseudo-states (distinct
|
|
18
|
+
compact glyph markers, shared across every [*] reference in one scope); state "Long name" as s1
|
|
19
|
+
aliases; state X { ... } composite states as nested subgraph frames (a composite referenced
|
|
20
|
+
directly by an external transition gets its own proxy box, since the engine has no edge-to-frame
|
|
21
|
+
anchor); <<choice>> as a diamond, <<fork>>/<<join>> degrading to the engine's plain small rect;
|
|
22
|
+
direction LR/TD/RL/BT; note left/right of X (inline and multi-line note/end note forms) attached
|
|
23
|
+
via a headless dotted edge so text is never dropped; %% comments skipped anywhere including inside
|
|
24
|
+
note blocks; -- / --- concurrency separators flattened.
|
|
25
|
+
|
|
26
|
+
Unwired: not imported by mermaid.py's dispatcher (later phase). Only touches new files; the
|
|
27
|
+
flowchart engine files remain untouched (read-only, per a concurrent sibling's ownership).
|
|
28
|
+
|
|
29
|
+
tests/test_mermaid_state.py: 24 tests asserting real topology/geometry (row/column ordering, frame
|
|
30
|
+
containment, distinct start/end glyphs, diamond-shape glyphs, label text) across a simple
|
|
31
|
+
start/end machine, labeled transitions, aliases, choice/fork/join, composite + nested composite
|
|
32
|
+
states, inline/multi-line notes, direction LR, comments, and the degradation contract
|
|
33
|
+
(header-missing, zero-node, and crash-guard echo paths, each pinned to the same three-condition
|
|
34
|
+
rule the flowchart engine's own tests already establish — best-effort recoverable body-level
|
|
35
|
+
glitches, like a stray '}' or an unterminated composite/note, render real content rather than
|
|
36
|
+
forcing an echo, matching mermaid_flow_parser.py's documented 'auto-closed rather than dropped' /
|
|
37
|
+
'consumed silently, best-effort' policy).
|
|
38
|
+
|
|
39
|
+
|
|
4
40
|
## v4.7.0 (2026-07-07)
|
|
5
41
|
|
|
6
42
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.8.0
|
|
4
4
|
Summary: Rich terminal rendering of directive-flavored markdown
|
|
5
5
|
Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
|
|
6
6
|
Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Native ASCII renderer for mermaid ``stateDiagram``/``stateDiagram-v2`` sources.
|
|
2
|
+
|
|
3
|
+
Standalone module: exposes a single pure function, ``render_state``. Not
|
|
4
|
+
wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned phase)
|
|
5
|
+
— nothing in this package imports it yet.
|
|
6
|
+
|
|
7
|
+
Layout model
|
|
8
|
+
------------
|
|
9
|
+
This module owns none of the layout math. A state diagram is translated
|
|
10
|
+
into the flowchart engine's own model (:class:`~termrender.renderers.
|
|
11
|
+
mermaid_flow_model.FlowGraph`/``FlowNode``/``FlowEdge``/``Subgraph``) and
|
|
12
|
+
handed straight to :func:`~termrender.renderers.mermaid_flow_layout.
|
|
13
|
+
layout_flowgraph` — the same grandalf-based Sugiyama layout, box
|
|
14
|
+
rasterizer, orthogonal edge router, and subgraph-frame drawer used by the
|
|
15
|
+
native flowchart renderer. States become boxes, transitions become edges,
|
|
16
|
+
composite ``state X { ... }`` blocks become subgraph frames — this module
|
|
17
|
+
is purely the state-diagram-to-flowchart-model adapter (parse + translate);
|
|
18
|
+
:func:`render_state` is ``parse -> layout_flowgraph -> lines``, wrapped in
|
|
19
|
+
a degradation guard, mirroring ``mermaid_flow.py``'s own public contract
|
|
20
|
+
and the same box-glyph-presence success signal the crouter attach viewer
|
|
21
|
+
depends on.
|
|
22
|
+
|
|
23
|
+
Grammar supported
|
|
24
|
+
------------------
|
|
25
|
+
Header: ``stateDiagram``/``stateDiagram-v2``. Transitions: ``A --> B`` and
|
|
26
|
+
``A --> B : event`` (the event becomes the edge label). ``[*]`` as a
|
|
27
|
+
pseudo-state: as a transition source it is the (per-scope) start marker,
|
|
28
|
+
rendered as a compact rounded box holding a single ``\u25cf`` (filled
|
|
29
|
+
circle) glyph; as a transition destination it is the (per-scope) end
|
|
30
|
+
marker, holding a single ``\u25c9`` (fisheye) glyph instead — same shape,
|
|
31
|
+
distinct glyph, so start and end read differently without a bespoke
|
|
32
|
+
border. Each lexical scope (the top level, or one per composite state) has
|
|
33
|
+
its own shared start/end marker, created lazily and reused across every
|
|
34
|
+
``[*]`` reference within that scope — mirrors mermaid's "one implicit
|
|
35
|
+
start/end per machine" semantics. ``state "Long name" as s1`` aliases a
|
|
36
|
+
plain state's display label; the same form immediately followed by ``{``
|
|
37
|
+
both aliases *and* opens a composite. ``state X { ... }`` (with or without
|
|
38
|
+
an alias) opens a composite state, translated to a
|
|
39
|
+
:class:`~termrender.renderers.mermaid_flow_model.Subgraph`: transitions and
|
|
40
|
+
sub-states declared inside become members of that subgraph (nesting via
|
|
41
|
+
``Subgraph.children``, exactly as deep as the source nests), and are drawn
|
|
42
|
+
inside the engine's enclosing frame. A composite id referenced directly by
|
|
43
|
+
a transition *outside* its own block (entering/exiting the composite as a
|
|
44
|
+
whole, rather than one of its substates) gets its own proxy
|
|
45
|
+
:class:`FlowNode` — the engine has no notion of an edge terminating on a
|
|
46
|
+
frame's border, so this proxy renders as an ordinary small box near (not
|
|
47
|
+
inside) the frame; a composite never referenced this way gets no such
|
|
48
|
+
box. ``state X <<choice>>`` marks ``X`` a choice pseudo-state (rendered as
|
|
49
|
+
the engine's existing compact diamond); ``<<fork>>``/``<<join>>`` degrade
|
|
50
|
+
to the engine's plain small rectangle (no bespoke bar glyph exists in the
|
|
51
|
+
underlying engine — "a small box" is the documented, explicit fallback for
|
|
52
|
+
these two). ``direction LR``/``TD``/``TB``/``RL``/``BT`` on its own line
|
|
53
|
+
sets the whole diagram's rank-flow direction (last one wins if repeated).
|
|
54
|
+
``note left of X : text`` and the multi-line ``note left of X`` / ... /
|
|
55
|
+
``end note`` block form both attach: the note becomes its own small boxed
|
|
56
|
+
node holding the note text, joined to ``X`` by a plain undirected dotted
|
|
57
|
+
line (no arrowhead either end) — the engine has no "float this box beside
|
|
58
|
+
that one" primitive, so the left/right side isn't honored, but the text
|
|
59
|
+
itself is never dropped (see *Known degradations*). A lone ``--`` or
|
|
60
|
+
``---`` line inside a composite (mermaid's concurrent-region separator) is
|
|
61
|
+
consumed with no effect — the regions on either side flatten into one
|
|
62
|
+
shared sibling scope rather than rendering as visually distinct lanes.
|
|
63
|
+
``%%`` line comments are skipped anywhere, including inside a multi-line
|
|
64
|
+
``note ... / end note`` block (a comment line there is dropped, not folded
|
|
65
|
+
into the note text).
|
|
66
|
+
|
|
67
|
+
Known degradations (by design, not bugs)
|
|
68
|
+
-----------------------------------------
|
|
69
|
+
- Note position (``left of``/``right of``) has no engine-level lever (node
|
|
70
|
+
placement comes from grandalf's layering, not manual coordinates) and is
|
|
71
|
+
not honored; the note still renders, just wherever the graph places it,
|
|
72
|
+
with its full text intact.
|
|
73
|
+
- A composite id referenced by an external transition before its own
|
|
74
|
+
``state X { ... }``/``state "..." as X { ... }`` declaration is seen
|
|
75
|
+
(a forward reference) gets its proxy box labeled with the bare id rather
|
|
76
|
+
than the composite's eventual display title — declaration-order
|
|
77
|
+
dependency, matching the equivalent limitation already documented for
|
|
78
|
+
the flowchart parser's node re-declaration policy.
|
|
79
|
+
- Fork/join pseudo-states render as the engine's plain small rectangle,
|
|
80
|
+
not a bar — see *Grammar supported* above.
|
|
81
|
+
- Everything else the underlying flowchart engine already documents as a
|
|
82
|
+
degradation (dense-graph crossings, CJK label wrapping, minimum-box-size
|
|
83
|
+
cosmetics, a subgraph that isn't contiguous enough for a clean frame
|
|
84
|
+
flattening instead) applies unchanged, since this module draws through
|
|
85
|
+
that engine. See ``mermaid_flow_layout.py``'s and ``mermaid_flow.py``'s
|
|
86
|
+
module docstrings.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
from __future__ import annotations
|
|
90
|
+
|
|
91
|
+
import re
|
|
92
|
+
from dataclasses import dataclass
|
|
93
|
+
|
|
94
|
+
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
95
|
+
from termrender.renderers.mermaid_flow_model import (
|
|
96
|
+
Direction,
|
|
97
|
+
EdgeStyle,
|
|
98
|
+
FlowEdge,
|
|
99
|
+
FlowGraph,
|
|
100
|
+
FlowNode,
|
|
101
|
+
NodeShape,
|
|
102
|
+
Subgraph,
|
|
103
|
+
)
|
|
104
|
+
from termrender.renderers.mermaid_prelude import strip_prelude_lines
|
|
105
|
+
|
|
106
|
+
__all__ = ["render_state", "StateDiagramError"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class StateDiagramError(Exception):
|
|
110
|
+
"""Raised when source cannot be parsed as a mermaid state diagram at all."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# Grammar
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
_HEADER_RE = re.compile(r"^stateDiagram(-v2)?\b", re.IGNORECASE)
|
|
118
|
+
_DIRECTION_RE = re.compile(r"^direction\s+(TB|TD|LR|RL|BT)\s*$", re.IGNORECASE)
|
|
119
|
+
_COMMENT_RE = re.compile(r"^%%")
|
|
120
|
+
_CONCURRENCY_SEP_RE = re.compile(r"^-{2,}\s*$")
|
|
121
|
+
|
|
122
|
+
_ALIAS_RE = re.compile(r'^state\s+"([^"]*)"\s+as\s+(\S+?)\s*(\{)?\s*$', re.IGNORECASE)
|
|
123
|
+
_COMPOSITE_START_RE = re.compile(r"^state\s+(\S+)\s*\{\s*$", re.IGNORECASE)
|
|
124
|
+
_COMPOSITE_END_RE = re.compile(r"^\}\s*$")
|
|
125
|
+
_ANNOTATION_RE = re.compile(
|
|
126
|
+
r"^state\s+(\S+)\s*<<\s*(choice|fork|join)\s*>>\s*$", re.IGNORECASE
|
|
127
|
+
)
|
|
128
|
+
_BARE_STATE_RE = re.compile(r"^state\s+(\S+)\s*$", re.IGNORECASE)
|
|
129
|
+
|
|
130
|
+
_NOTE_INLINE_RE = re.compile(
|
|
131
|
+
r"^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$", re.IGNORECASE
|
|
132
|
+
)
|
|
133
|
+
_NOTE_BLOCK_START_RE = re.compile(
|
|
134
|
+
r"^note\s+(left|right)\s+of\s+(\S+)\s*$", re.IGNORECASE
|
|
135
|
+
)
|
|
136
|
+
_NOTE_END_RE = re.compile(r"^end\s+note\s*$", re.IGNORECASE)
|
|
137
|
+
|
|
138
|
+
# Non-greedy src: a plain id never legitimately contains "-->", but the
|
|
139
|
+
# greedy alternative would still (via backtracking) find *a* match — lazy
|
|
140
|
+
# quantification just makes it find the intended (leftmost) one directly.
|
|
141
|
+
_TRANSITION_RE = re.compile(
|
|
142
|
+
r"^(?P<src>\[\*\]|\S+?)\s*-->\s*(?P<dst>\[\*\]|\S+)\s*(?::\s*(?P<label>.*))?$"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
_START_GLYPH = "\u25cf" # ● filled circle — start pseudo-state marker
|
|
146
|
+
_END_GLYPH = "\u25c9" # ◉ fisheye — end pseudo-state marker, distinct from start
|
|
147
|
+
|
|
148
|
+
_ANNOTATION_SHAPE: dict[str, NodeShape] = {
|
|
149
|
+
"choice": NodeShape.DIAMOND,
|
|
150
|
+
"fork": NodeShape.RECT,
|
|
151
|
+
"join": NodeShape.RECT,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class _Scope:
|
|
157
|
+
"""One lexical scope's ``[*]`` bookkeeping: the root, or one composite
|
|
158
|
+
state's body. ``key`` namespaces the synthetic start/end marker ids so
|
|
159
|
+
nested composites each get their own start/end rather than sharing the
|
|
160
|
+
root's."""
|
|
161
|
+
|
|
162
|
+
key: str
|
|
163
|
+
start_id: str | None = None
|
|
164
|
+
end_id: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------
|
|
168
|
+
# Small helpers
|
|
169
|
+
# --------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _norm_label(text: str | None) -> str | None:
|
|
173
|
+
if text is None:
|
|
174
|
+
return None
|
|
175
|
+
stripped = text.strip()
|
|
176
|
+
return stripped or None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pseudo_id(scope_key: str, kind: str) -> str:
|
|
180
|
+
return f"__{kind}__" if not scope_key else f"__{kind}_{scope_key}__"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _register(
|
|
184
|
+
nodes: dict[str, FlowNode],
|
|
185
|
+
stack: list[Subgraph],
|
|
186
|
+
node_id: str,
|
|
187
|
+
label: str | None = None,
|
|
188
|
+
shape: NodeShape | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Register (or update) a state node — mirrors the flowchart parser's
|
|
191
|
+
node re-declaration policy: an existing node's label/shape is updated
|
|
192
|
+
in place when a richer declaration supplies one, never downgraded by a
|
|
193
|
+
later bare reference. Adds ``node_id`` to the innermost currently-open
|
|
194
|
+
composite's member list (once), if any composite is open."""
|
|
195
|
+
existing = nodes.get(node_id)
|
|
196
|
+
if existing is None:
|
|
197
|
+
nodes[node_id] = FlowNode(
|
|
198
|
+
id=node_id,
|
|
199
|
+
label=label if label is not None else node_id,
|
|
200
|
+
shape=shape if shape is not None else NodeShape.RECT,
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
if label is not None:
|
|
204
|
+
existing.label = label
|
|
205
|
+
if shape is not None:
|
|
206
|
+
existing.shape = shape
|
|
207
|
+
if stack and node_id not in stack[-1].node_ids:
|
|
208
|
+
stack[-1].node_ids.append(node_id)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _resolve_endpoint(
|
|
212
|
+
token: str,
|
|
213
|
+
kind: str,
|
|
214
|
+
scope: _Scope,
|
|
215
|
+
nodes: dict[str, FlowNode],
|
|
216
|
+
stack: list[Subgraph],
|
|
217
|
+
composites: dict[str, Subgraph],
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Resolve one transition endpoint token to a graph node id: ``[*]``
|
|
220
|
+
becomes the current scope's shared start (``kind == "start"``) or end
|
|
221
|
+
(``kind == "end"``) marker, created lazily on first reference; any
|
|
222
|
+
other token is registered as an ordinary state (using the composite's
|
|
223
|
+
display title if the token happens to be a known composite id, so a
|
|
224
|
+
transition that targets a composite directly gets a sensibly-labeled
|
|
225
|
+
proxy box — see the module docstring)."""
|
|
226
|
+
if token == "[*]":
|
|
227
|
+
if kind == "start":
|
|
228
|
+
if scope.start_id is None:
|
|
229
|
+
scope.start_id = _pseudo_id(scope.key, "start")
|
|
230
|
+
_register(
|
|
231
|
+
nodes, stack, scope.start_id, label=_START_GLYPH, shape=NodeShape.ROUND
|
|
232
|
+
)
|
|
233
|
+
return scope.start_id
|
|
234
|
+
if scope.end_id is None:
|
|
235
|
+
scope.end_id = _pseudo_id(scope.key, "end")
|
|
236
|
+
_register(
|
|
237
|
+
nodes, stack, scope.end_id, label=_END_GLYPH, shape=NodeShape.ROUND
|
|
238
|
+
)
|
|
239
|
+
return scope.end_id
|
|
240
|
+
|
|
241
|
+
if token not in nodes:
|
|
242
|
+
label = composites[token].title if token in composites else None
|
|
243
|
+
_register(nodes, stack, token, label=label)
|
|
244
|
+
return token
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _attach_note(
|
|
248
|
+
nodes: dict[str, FlowNode],
|
|
249
|
+
edges: list[FlowEdge],
|
|
250
|
+
stack: list[Subgraph],
|
|
251
|
+
composites: dict[str, Subgraph],
|
|
252
|
+
target: str | None,
|
|
253
|
+
text: str,
|
|
254
|
+
counters: dict[str, int],
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Attach a note as its own small boxed node, joined to ``target`` by a
|
|
257
|
+
plain (headless) dotted line. Never drops non-empty note text (see the
|
|
258
|
+
module docstring's degradation notes on *why* the box floats wherever
|
|
259
|
+
the graph places it rather than strictly left/right of ``target``)."""
|
|
260
|
+
stripped = text.strip()
|
|
261
|
+
if not target or not stripped:
|
|
262
|
+
return
|
|
263
|
+
if target not in nodes:
|
|
264
|
+
label = composites[target].title if target in composites else None
|
|
265
|
+
_register(nodes, stack, target, label=label)
|
|
266
|
+
counters["note"] += 1
|
|
267
|
+
note_id = f"__note_{counters['note']}__"
|
|
268
|
+
_register(nodes, stack, note_id, label=stripped, shape=NodeShape.RECT)
|
|
269
|
+
edges.append(
|
|
270
|
+
FlowEdge(
|
|
271
|
+
src=target,
|
|
272
|
+
dst=note_id,
|
|
273
|
+
style=EdgeStyle.DOTTED,
|
|
274
|
+
label=None,
|
|
275
|
+
dst_arrow=False,
|
|
276
|
+
src_arrow=False,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _close_composite(stack: list[Subgraph], roots: list[Subgraph]) -> None:
|
|
282
|
+
done = stack.pop()
|
|
283
|
+
if stack:
|
|
284
|
+
stack[-1].children.append(done)
|
|
285
|
+
else:
|
|
286
|
+
roots.append(done)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------
|
|
290
|
+
# Public entry point
|
|
291
|
+
# --------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def parse(source: str) -> FlowGraph:
|
|
295
|
+
"""Parse a mermaid ``stateDiagram``/``stateDiagram-v2`` source into a
|
|
296
|
+
:class:`~termrender.renderers.mermaid_flow_model.FlowGraph` — the
|
|
297
|
+
flowchart engine's own model, so :func:`~termrender.renderers.
|
|
298
|
+
mermaid_flow_layout.layout_flowgraph` can render it directly.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
source: The mermaid fence body; its first non-blank line (after
|
|
302
|
+
skipping the standard mermaid prelude) must start with
|
|
303
|
+
``stateDiagram``.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
A populated :class:`FlowGraph`. A structurally-empty but headed
|
|
307
|
+
diagram (blank/comment-only body) returns a graph with no nodes.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
StateDiagramError: If ``source`` is not a mermaid state diagram at
|
|
311
|
+
all (missing the ``stateDiagram`` header).
|
|
312
|
+
"""
|
|
313
|
+
lines = source.splitlines()
|
|
314
|
+
sniff_lines = strip_prelude_lines(lines)
|
|
315
|
+
first = next((line.strip() for line in sniff_lines if line.strip()), "")
|
|
316
|
+
if not _HEADER_RE.match(first):
|
|
317
|
+
raise StateDiagramError(
|
|
318
|
+
"not a mermaid state diagram: source must start with 'stateDiagram'"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
direction = Direction.TB
|
|
322
|
+
nodes: dict[str, FlowNode] = {}
|
|
323
|
+
edges: list[FlowEdge] = []
|
|
324
|
+
composites: dict[str, Subgraph] = {}
|
|
325
|
+
roots: list[Subgraph] = []
|
|
326
|
+
stack: list[Subgraph] = []
|
|
327
|
+
scopes: list[_Scope] = [_Scope(key="")]
|
|
328
|
+
counters = {"note": 0}
|
|
329
|
+
seen_header = False
|
|
330
|
+
note_target: str | None = None
|
|
331
|
+
note_lines: list[str] | None = None
|
|
332
|
+
|
|
333
|
+
for raw in lines:
|
|
334
|
+
line = raw.strip()
|
|
335
|
+
if not line:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if note_lines is not None:
|
|
339
|
+
if _NOTE_END_RE.match(line):
|
|
340
|
+
_attach_note(
|
|
341
|
+
nodes, edges, stack, composites, note_target,
|
|
342
|
+
" ".join(note_lines), counters,
|
|
343
|
+
)
|
|
344
|
+
note_target = None
|
|
345
|
+
note_lines = None
|
|
346
|
+
elif not _COMMENT_RE.match(line):
|
|
347
|
+
note_lines.append(line)
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
if not seen_header:
|
|
351
|
+
if _HEADER_RE.match(line):
|
|
352
|
+
seen_header = True
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if _COMMENT_RE.match(line):
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
if _CONCURRENCY_SEP_RE.match(line):
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
m = _DIRECTION_RE.match(line)
|
|
362
|
+
if m:
|
|
363
|
+
token = m.group(1).upper()
|
|
364
|
+
direction = Direction.TB if token == "TD" else Direction(token)
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
m = _NOTE_INLINE_RE.match(line)
|
|
368
|
+
if m:
|
|
369
|
+
_attach_note(
|
|
370
|
+
nodes, edges, stack, composites, m.group(2), m.group(3), counters
|
|
371
|
+
)
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
m = _NOTE_BLOCK_START_RE.match(line)
|
|
375
|
+
if m:
|
|
376
|
+
note_target = m.group(2)
|
|
377
|
+
note_lines = []
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
m = _ALIAS_RE.match(line)
|
|
381
|
+
if m:
|
|
382
|
+
title, node_id, opens_brace = m.groups()
|
|
383
|
+
display = title.strip() or node_id
|
|
384
|
+
if opens_brace:
|
|
385
|
+
sub = Subgraph(id=node_id, title=display)
|
|
386
|
+
composites[node_id] = sub
|
|
387
|
+
stack.append(sub)
|
|
388
|
+
scopes.append(_Scope(key=node_id))
|
|
389
|
+
else:
|
|
390
|
+
_register(nodes, stack, node_id, label=display)
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
m = _COMPOSITE_START_RE.match(line)
|
|
394
|
+
if m:
|
|
395
|
+
node_id = m.group(1)
|
|
396
|
+
sub = Subgraph(id=node_id, title=node_id)
|
|
397
|
+
composites[node_id] = sub
|
|
398
|
+
stack.append(sub)
|
|
399
|
+
scopes.append(_Scope(key=node_id))
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if _COMPOSITE_END_RE.match(line):
|
|
403
|
+
if stack:
|
|
404
|
+
_close_composite(stack, roots)
|
|
405
|
+
if len(scopes) > 1:
|
|
406
|
+
scopes.pop()
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
m = _ANNOTATION_RE.match(line)
|
|
410
|
+
if m:
|
|
411
|
+
node_id, kind = m.groups()
|
|
412
|
+
_register(nodes, stack, node_id, shape=_ANNOTATION_SHAPE[kind.lower()])
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
m = _TRANSITION_RE.match(line)
|
|
416
|
+
if m:
|
|
417
|
+
scope = scopes[-1]
|
|
418
|
+
src_id = _resolve_endpoint(
|
|
419
|
+
m.group("src"), "start", scope, nodes, stack, composites
|
|
420
|
+
)
|
|
421
|
+
dst_id = _resolve_endpoint(
|
|
422
|
+
m.group("dst"), "end", scope, nodes, stack, composites
|
|
423
|
+
)
|
|
424
|
+
edges.append(
|
|
425
|
+
FlowEdge(
|
|
426
|
+
src=src_id,
|
|
427
|
+
dst=dst_id,
|
|
428
|
+
style=EdgeStyle.SOLID,
|
|
429
|
+
label=_norm_label(m.group("label")),
|
|
430
|
+
dst_arrow=True,
|
|
431
|
+
src_arrow=False,
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
m = _BARE_STATE_RE.match(line)
|
|
437
|
+
if m:
|
|
438
|
+
_register(nodes, stack, m.group(1))
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
# Unrecognized body line: consumed silently, best-effort.
|
|
442
|
+
|
|
443
|
+
if note_lines is not None:
|
|
444
|
+
_attach_note(
|
|
445
|
+
nodes, edges, stack, composites, note_target,
|
|
446
|
+
" ".join(note_lines), counters,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
while stack:
|
|
450
|
+
_close_composite(stack, roots)
|
|
451
|
+
|
|
452
|
+
return FlowGraph(
|
|
453
|
+
direction=direction,
|
|
454
|
+
nodes=list(nodes.values()),
|
|
455
|
+
edges=edges,
|
|
456
|
+
subgraphs=roots,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# Same ranges the downstream attach viewer uses to detect render *success*
|
|
461
|
+
# (see mermaid_flow.py's identical guard). The raw-echo path must never
|
|
462
|
+
# contain one of these, even when malformed/degenerate source itself
|
|
463
|
+
# happens to contain a literal box-drawing or geometric glyph.
|
|
464
|
+
_GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def render_state(source: str, width: int) -> list[str]:
|
|
468
|
+
"""Render a mermaid ``stateDiagram``/``stateDiagram-v2`` source to
|
|
469
|
+
unicode lines.
|
|
470
|
+
|
|
471
|
+
Pure and total: never raises. Degrades to a raw echo of ``source``
|
|
472
|
+
(each line ``\\n``-split and ``.rstrip()``-ed, containing no
|
|
473
|
+
box-drawing glyphs) exactly when:
|
|
474
|
+
|
|
475
|
+
1. The source isn't a state diagram at all (no ``stateDiagram`` header
|
|
476
|
+
on its first non-blank, post-prelude line) — :func:`parse` raises
|
|
477
|
+
:class:`StateDiagramError`.
|
|
478
|
+
2. Parsing succeeds but yields zero states (an empty or comment-only
|
|
479
|
+
diagram body) — nothing to draw.
|
|
480
|
+
3. Any unexpected exception escapes :func:`~termrender.renderers.
|
|
481
|
+
mermaid_flow_layout.layout_flowgraph` — a defensive catch-all; this
|
|
482
|
+
should never happen for well-typed input, but the guarantee is
|
|
483
|
+
"never crash, degrade" regardless.
|
|
484
|
+
|
|
485
|
+
Otherwise, returns the rendered diagram: guaranteed to contain unicode
|
|
486
|
+
box-drawing glyphs (every state is a bordered box), never ANSI escapes.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
source: The mermaid fence body (with or without the surrounding
|
|
490
|
+
fence markers — only the text between them).
|
|
491
|
+
width: Advisory terminal width; sized to content, may overflow
|
|
492
|
+
(see the module docstring's "Known degradations", inherited
|
|
493
|
+
from the underlying flowchart engine).
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Rendered lines on success, or a raw-echo of ``source`` on any of
|
|
497
|
+
the three degradation conditions above.
|
|
498
|
+
"""
|
|
499
|
+
try:
|
|
500
|
+
graph = parse(source)
|
|
501
|
+
except StateDiagramError:
|
|
502
|
+
return _raw_echo(source)
|
|
503
|
+
|
|
504
|
+
if not graph.nodes:
|
|
505
|
+
return _raw_echo(source)
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
lines = layout_flowgraph(graph, width)
|
|
509
|
+
except Exception:
|
|
510
|
+
return _raw_echo(source)
|
|
511
|
+
|
|
512
|
+
if not lines:
|
|
513
|
+
return _raw_echo(source)
|
|
514
|
+
|
|
515
|
+
return lines
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _raw_echo(source: str) -> list[str]:
|
|
519
|
+
return [_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]
|