termrender 4.10.2__tar.gz → 4.10.3__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 (80) hide show
  1. {termrender-4.10.2 → termrender-4.10.3}/CHANGELOG.md +25 -0
  2. {termrender-4.10.2 → termrender-4.10.3}/PKG-INFO +1 -1
  3. {termrender-4.10.2 → termrender-4.10.3}/design.json +8 -8
  4. {termrender-4.10.2 → termrender-4.10.3}/pyproject.toml +6 -0
  5. {termrender-4.10.2 → termrender-4.10.3}/requirements.json +6 -6
  6. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/CLAUDE.md +2 -2
  7. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid.py +5 -29
  8. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_class.py +70 -26
  9. termrender-4.10.3/src/termrender/renderers/mermaid_degradation.py +34 -0
  10. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_er.py +34 -26
  11. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_flow.py +17 -33
  12. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_flow_parser.py +71 -29
  13. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_state.py +36 -31
  14. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_class.py +50 -0
  15. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_er.py +36 -0
  16. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow.py +59 -1
  17. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow_parser.py +36 -13
  18. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_state.py +60 -11
  19. termrender-4.10.3/uv.lock +198 -0
  20. termrender-4.10.2/uv.lock +0 -70
  21. {termrender-4.10.2 → termrender-4.10.3}/.github/workflows/publish.yml +0 -0
  22. {termrender-4.10.2 → termrender-4.10.3}/.gitignore +0 -0
  23. {termrender-4.10.2 → termrender-4.10.3}/CLAUDE.md +0 -0
  24. {termrender-4.10.2 → termrender-4.10.3}/LICENSE +0 -0
  25. {termrender-4.10.2 → termrender-4.10.3}/README.md +0 -0
  26. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/CLAUDE.md +0 -0
  27. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/__init__.py +0 -0
  28. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/__main__.py +0 -0
  29. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/blocks.py +0 -0
  30. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/emit.py +0 -0
  31. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/layout.py +0 -0
  32. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/parser.py +0 -0
  33. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/py.typed +0 -0
  34. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/__init__.py +0 -0
  35. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/borders.py +0 -0
  36. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/charts.py +0 -0
  37. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/code.py +0 -0
  38. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/columns.py +0 -0
  39. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/diff.py +0 -0
  40. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/divider.py +0 -0
  41. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
  42. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  43. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_gantt.py +0 -0
  44. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_journey.py +0 -0
  45. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  46. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_pie.py +0 -0
  47. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_prelude.py +0 -0
  48. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_sequence.py +0 -0
  49. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/mermaid_timeline.py +0 -0
  50. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/panel.py +0 -0
  51. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/quote.py +0 -0
  52. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/stat.py +0 -0
  53. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/table.py +0 -0
  54. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/text.py +0 -0
  55. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/timeline.py +0 -0
  56. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/renderers/tree.py +0 -0
  57. {termrender-4.10.2 → termrender-4.10.3}/src/termrender/style.py +0 -0
  58. {termrender-4.10.2 → termrender-4.10.3}/tests/__init__.py +0 -0
  59. {termrender-4.10.2 → termrender-4.10.3}/tests/test_charts.py +0 -0
  60. {termrender-4.10.2 → termrender-4.10.3}/tests/test_cli_contract.py +0 -0
  61. {termrender-4.10.2 → termrender-4.10.3}/tests/test_column_alignment.py +0 -0
  62. {termrender-4.10.2 → termrender-4.10.3}/tests/test_diff.py +0 -0
  63. {termrender-4.10.2 → termrender-4.10.3}/tests/test_inline_badge.py +0 -0
  64. {termrender-4.10.2 → termrender-4.10.3}/tests/test_linebreak.py +0 -0
  65. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_dispatch.py +0 -0
  66. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow_corpus.py +0 -0
  67. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow_layout.py +0 -0
  68. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow_model.py +0 -0
  69. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_flow_shapes.py +0 -0
  70. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_gantt.py +0 -0
  71. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_journey.py +0 -0
  72. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_mindmap.py +0 -0
  73. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_pie.py +0 -0
  74. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_sequence.py +0 -0
  75. {termrender-4.10.2 → termrender-4.10.3}/tests/test_mermaid_timeline.py +0 -0
  76. {termrender-4.10.2 → termrender-4.10.3}/tests/test_myst_gaps.py +0 -0
  77. {termrender-4.10.2 → termrender-4.10.3}/tests/test_stat.py +0 -0
  78. {termrender-4.10.2 → termrender-4.10.3}/tests/test_tasklist.py +0 -0
  79. {termrender-4.10.2 → termrender-4.10.3}/tests/test_timeline.py +0 -0
  80. {termrender-4.10.2 → termrender-4.10.3}/tests/test_variable_colons.py +0 -0
@@ -1,6 +1,31 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.10.3 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid**: Strict degradation — unrecognized syntax raw-echoes instead of half-rendering
9
+ ([`904d1cc`](https://github.com/crouton-labs/termrender/commit/904d1ccaa5c012bbeefdc8f351fb25164b0c3778))
10
+
11
+ Enforce the native Mermaid degradation contract across flowchart, classDiagram, stateDiagram-v2, and
12
+ erDiagram renderers: only well-formed presentational/accessibility directives are skipped, while
13
+ unsupported statements, dangling connectors, stray closers, and unterminated structural blocks now
14
+ fail parsing and raw-echo the entire original source.
15
+
16
+ Consolidate raw-echo glyph scrubbing in mermaid_degradation.py and update the dispatcher/renderers
17
+ to import the shared helper so every degradation path removes box/geometric glyphs consistently.
18
+
19
+ Flip the parser-level tests that previously pinned best-effort recovery for malformed flowcharts to
20
+ expect FlowchartError, and flip the state stray-close-brace render test to expect raw echo. Add
21
+ regression coverage for the review repros, malformed directive-shaped lines, valid accessibility
22
+ colon forms, and native rendering of valid presentational directives.
23
+
24
+ Refresh renderer docs and architecture metadata away from the removed Go/mermaid-ascii path, and add
25
+ minimal pytest project config so the required exact 'uv run pytest' gate imports the src package
26
+ in this repository.
27
+
28
+
4
29
  ## v4.10.2 (2026-07-07)
5
30
 
6
31
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.10.2
3
+ Version: 4.10.3
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
@@ -99,8 +99,8 @@
99
99
  "id": "DES-005",
100
100
  "title": "Mermaid block detection",
101
101
  "description": "How mermaid code blocks are identified and stored in the AST",
102
- "content": "Mermaid blocks are standard fenced code blocks with the 'mermaid' language tag:\n\n ```mermaid\n graph TD\n A[Build] --> B[Test]\n ```\n\nThe markdown parser already handles fenced code blocks. We just need to:\n\n1. Detect code blocks where language == 'mermaid'\n2. Store the raw mermaid source in the Block's attrs\n3. At render time, pass the source to mermaid-ascii for rendering\n\nThe Block node:\n\n Block(\n type=BlockType.MERMAID,\n children=[],\n text=[],\n attrs={\"source\": \"graph TD\\n A[Build] --> B[Test]\"}\n )\n\nNo special parsing of the mermaid syntax itselfthat's mermaid-ascii's job.",
103
- "agentNotes": "We deliberately don't parse mermaid syntax ourselves. mermaid-ascii handles it. We just detect it, store it, and render it at emit time. The only thing we need from the mermaid output is its dimensions (width x height in characters) for the layout engine.",
102
+ "content": "Mermaid blocks are standard fenced code blocks with the 'mermaid' language tag:\n\n ```mermaid\n graph TD\n A[Build] --> B[Test]\n ```\n\nThe markdown parser already handles fenced code blocks. We just need to:\n\n1. Detect code blocks where language == 'mermaid'\n2. Store the raw mermaid source in the Block's attrs\n3. At render time, dispatch the source to the native renderer for its diagram type\n\nThe Block node:\n\n Block(\n type=BlockType.MERMAID,\n children=[],\n text=[],\n attrs={\"source\": \"graph TD\\n A[Build] --> B[Test]\"}\n )\n\nMermaid syntax is parsed by termrender's own native Python parsers, one per diagram type (renderers/mermaid_flow_parser.py, mermaid_class.py, mermaid_state.py, mermaid_er.py, and dedicated modules for pie/gantt/sequence/mindmap/journey/timeline) there is no external mermaid rendering dependency.",
103
+ "agentNotes": "We parse mermaid syntax ourselves with a native Python parser per diagram type; the flowchart/class/state/ER parsers share one grandalf-based layout engine (mermaid_flow_layout.py) via a common FlowGraph model. We still only need the rendered output's dimensions (width x height in characters) for the layout engine.",
104
104
  "status": "draft",
105
105
  "userNotes": "",
106
106
  "reviewAction": null,
@@ -219,8 +219,8 @@
219
219
  {
220
220
  "id": "DES-012",
221
221
  "title": "Mermaid renderer",
222
- "description": "How mermaid diagrams are rendered via mermaid-ascii",
223
- "content": "Mermaid blocks delegate to the mermaid-ascii library:\n\n from mermaid_ascii import render as mermaid_render\n\n def render_mermaid(block: Block) -> list[str]:\n source = block.attrs['source']\n output = mermaid_render(source) # returns rendered ASCII string\n lines = output.split('\\n')\n\n # clip to available width (hard wrap per REQ-008)\n return [line[:block.width] for line in lines]\n\nThe mermaid output is treated as a pre-rendered block no further word wrapping or layout. If it exceeds the available width, lines are clipped at the boundary.\n\nDuring the layout pass, we render the mermaid once and cache the output on the block to avoid double-rendering:\n\n block.attrs['_rendered'] = mermaid_render(source)\n block.height = block.attrs['_rendered'].count('\\n') + 1",
222
+ "description": "How mermaid diagrams are rendered by termrender's native per-type renderers",
223
+ "content": "Mermaid blocks dispatch by diagram type to a native Python renderer:\n\n from termrender.renderers.mermaid import render_mermaid_lines\n\n def render_mermaid(block: Block) -> list[str]:\n source = block.attrs['source']\n lines = render_mermaid_lines(source, block.width)\n return [visual_ljust(line, block.width) for line in lines]\n\nrender_mermaid_lines reads the diagram's first real line to pick its renderer (flowchart/class/state/ER share a grandalf-based Sugiyama layout engine; pie/gantt/sequence/mindmap/journey/timeline each have their own dedicated renderer). Any unrecognized type, or any input a renderer can't parse under its supported grammar, degrades to a raw echo of the source lines rather than a half-rendered diagram — see renderers/CLAUDE.md's degradation contract.\n\nDuring the layout pass, we render the mermaid once and cache the output on the block to avoid double-rendering:\n\n block.attrs['_rendered'] = '\\n'.join(render_mermaid_lines(source, width))\n block.height = block.attrs['_rendered'].count('\\n') + 1",
224
224
  "decision": {
225
225
  "proposal": {
226
226
  "title": "Pre-render during layout",
@@ -238,7 +238,7 @@
238
238
  "simplicity": "Pre-render is actually simpler — no estimation heuristics to maintain"
239
239
  }
240
240
  },
241
- "agentNotes": "This also answers OQ1 from the parser section — pre-render is the way to go. The cached output is stored as a private _rendered attr.",
241
+ "agentNotes": "This also answers OQ1 from the parser section — pre-render is the way to go. The cached output is stored as a private _rendered attr. No external mermaid rendering dependency: every diagram type is a native Python parser in renderers/.",
242
242
  "status": "draft",
243
243
  "userNotes": "",
244
244
  "reviewAction": "agree",
@@ -362,7 +362,7 @@
362
362
  "id": "DES-018",
363
363
  "title": "Project structure",
364
364
  "description": "Directory layout and module organization",
365
- "content": " termrender/\n ├── pyproject.toml\n ├── src/\n │ └── termrender/\n │ ├── __init__.py # render() — the public API\n │ ├── __main__.py # CLI entry point\n │ ├── parser.py # markdown + directive parsing\n │ ├── layout.py # width/height resolution\n │ ├── emit.py # ANSI string generation\n │ ├── style.py # ANSI color/style helpers\n │ ├── blocks.py # Block, InlineSpan dataclasses\n │ ├── renderers/\n │ │ ├── __init__.py\n │ │ ├── panel.py # panel + callout\n │ │ ├── columns.py # columns + col\n │ │ ├── tree.py # tree rendering\n │ │ ├── mermaid.py # mermaid-ascii integration\n │ │ ├── code.py # syntax-highlighted code\n │ │ ├── quote.py # blockquote with attribution\n │ │ ├── divider.py # horizontal rule with label\n │ │ └── text.py # paragraphs, headings, lists\n │ └── py.typed # PEP 561 type hint marker\n └── tests/\n ├── test_parser.py\n ├── test_layout.py\n ├── test_emit.py\n └── fixtures/ # .md input + expected output pairs\n\nUsing src/ layout (PEP 517) for clean packaging. Each renderer is a separate module for isolation — they can be tested independently.",
365
+ "content": " termrender/\n ├── pyproject.toml\n ├── src/\n │ └── termrender/\n │ ├── __init__.py # render() — the public API\n │ ├── __main__.py # CLI entry point\n │ ├── parser.py # markdown + directive parsing\n │ ├── layout.py # width/height resolution\n │ ├── emit.py # ANSI string generation\n │ ├── style.py # ANSI color/style helpers\n │ ├── blocks.py # Block, InlineSpan dataclasses\n │ ├── renderers/\n │ │ ├── __init__.py\n │ │ ├── panel.py # panel + callout\n │ │ ├── columns.py # columns + col\n │ │ ├── tree.py # tree rendering\n │ │ ├── mermaid.py # mermaid dispatch + native per-type renderers\n │ │ ├── code.py # syntax-highlighted code\n │ │ ├── quote.py # blockquote with attribution\n │ │ ├── divider.py # horizontal rule with label\n │ │ └── text.py # paragraphs, headings, lists\n │ └── py.typed # PEP 561 type hint marker\n └── tests/\n ├── test_parser.py\n ├── test_layout.py\n ├── test_emit.py\n └── fixtures/ # .md input + expected output pairs\n\nUsing src/ layout (PEP 517) for clean packaging. Each renderer is a separate module for isolation — they can be tested independently.",
366
366
  "agentNotes": "The renderer-per-file structure means adding a new directive type is: add a file in renderers/, add the BlockType enum value, register it in the emit dispatcher. No existing code needs to change.",
367
367
  "status": "draft",
368
368
  "userNotes": "",
@@ -374,8 +374,8 @@
374
374
  "id": "DES-019",
375
375
  "title": "Dependencies",
376
376
  "description": "Runtime and dev dependencies",
377
- "content": "Runtime dependencies:\n\n Dependency Purpose Size\n ────────── ─────── ────\n mistune >=3.0 Markdown parsing ~2k lines\n mermaid-ascii Mermaid diagram rendering ~5k lines\n pygments Syntax highlighting large but ubiquitous\n\nDev dependencies:\n\n pytest Testing\n ruff Linting + formatting\n mypy Type checking\n build Package building\n\nTotal runtime deps: 3. All pure Python, no native extensions, no subprocess calls.\n\npyproject.toml:\n\n [project]\n name = \"termrender\"\n requires-python = \">=3.10\"\n dependencies = [\n \"mistune>=3.0\",\n \"mermaid-ascii>=1.0\",\n \"pygments>=2.0\",\n ]\n\n [project.scripts]\n termrender = \"termrender.__main__:main\"\n\n [build-system]\n requires = [\"hatchling\"]\n build-backend = \"hatchling.build\"",
378
- "agentNotes": "Three deps is lean for what this does. Pygments is the heaviest but it's already installed in most Python environments (IPython, Jupyter, Sphinx all depend on it). Could be made optional with a graceful fallback to plain text, but that's over-engineering for v1.",
377
+ "content": "Runtime dependencies:\n\n Dependency Purpose Size\n ────────── ─────── ────\n mistune >=3.0 Markdown parsing ~2k lines\n grandalf >=0.8 Graph layout (mermaid) small, pure Python\n pygments Syntax highlighting large but ubiquitous\n\nDev dependencies:\n\n pytest Testing\n ruff Linting + formatting\n mypy Type checking\n build Package building\n\nTotal runtime deps: 3. All pure Python, no native extensions, no subprocess calls.\n\npyproject.toml:\n\n [project]\n name = \"termrender\"\n requires-python = \">=3.10\"\n dependencies = [\n \"mistune>=3.0\",\n \"pygments>=2.0\",\n \"grandalf>=0.8\",\n ]\n\n [project.scripts]\n termrender = \"termrender.__main__:main\"\n\n [build-system]\n requires = [\"hatchling\"]\n build-backend = \"hatchling.build\"",
378
+ "agentNotes": "Three deps is lean for what this does. Pygments is the heaviest but it's already installed in most Python environments (IPython, Jupyter, Sphinx all depend on it). grandalf is a small pure-Python graph layout library used only by the mermaid flowchart-model renderers (flow/class/state/ER).",
379
379
  "status": "draft",
380
380
  "userNotes": "",
381
381
  "reviewAction": null,
@@ -37,12 +37,18 @@ Homepage = "https://github.com/CaptainCrouton89/termrender"
37
37
  Repository = "https://github.com/CaptainCrouton89/termrender"
38
38
  Issues = "https://github.com/CaptainCrouton89/termrender/issues"
39
39
 
40
+ [dependency-groups]
41
+ dev = ["pytest>=9.0"]
42
+
40
43
  [project.scripts]
41
44
  termrender = "termrender.__main__:main"
42
45
 
43
46
  [tool.hatch.version]
44
47
  source = "vcs"
45
48
 
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["src"]
51
+
46
52
  [tool.hatch.build.targets.wheel]
47
53
  packages = ["src/termrender"]
48
54
 
@@ -112,7 +112,7 @@
112
112
  "title": "Mermaid code blocks",
113
113
  "ears": {
114
114
  "when": "When the input contains a fenced code block with language 'mermaid'",
115
- "shall": "the system shall parse the mermaid syntax and render it as an ASCII/Unicode flowchart inline with surrounding content, using mermaid-ascii as the rendering backend"
115
+ "shall": "the system shall parse the mermaid syntax and render it as an ASCII/Unicode flowchart inline with surrounding content, using termrender's own native Python parsers (no external mermaid rendering dependency)"
116
116
  },
117
117
  "criteria": [
118
118
  {
@@ -136,12 +136,12 @@
136
136
  "checked": false
137
137
  },
138
138
  {
139
- "text": "mermaid-ascii is used as a Python dependency, not shelled out to",
139
+ "text": "diagrams are parsed and rendered in-process by native Python parsers, with no subprocess or vendored binary dependency",
140
140
  "checked": false
141
141
  }
142
142
  ],
143
143
  "status": "draft",
144
- "agentNotes": "Revised per review since this is a Python package, we use mermaid-ascii directly as a library dependency. No subprocess overhead, no Python runtime concern. Sequence diagram support depends on what mermaid-ascii supports.",
144
+ "agentNotes": "Each mermaid diagram type has its own native Python parser in renderers/ (mermaid_flow_parser.py, mermaid_class.py, mermaid_state.py, mermaid_er.py, plus dedicated pie/gantt/sequence/mindmap/journey/timeline modules); the flowchart/class/state/ER parsers share one grandalf-based Sugiyama layout engine via a common FlowGraph model.",
145
145
  "userNotes": "User: 'We're going to make this a python package, so it's okay if it has python deps'",
146
146
  "questions": [],
147
147
  "startedAt": "2026-04-05T03:25:14.714Z",
@@ -479,7 +479,7 @@
479
479
  "title": "Standalone Python package",
480
480
  "ears": {
481
481
  "while": "While the package is distributed",
482
- "shall": "the system shall be a standalone Python package on PyPI, installable independently of sisyphus, with mermaid-ascii and a markdown parser as its primary dependencies"
482
+ "shall": "the system shall be a standalone Python package on PyPI, installable independently of sisyphus, with mistune (markdown) and grandalf (graph layout for native mermaid rendering) as its primary dependencies"
483
483
  },
484
484
  "criteria": [
485
485
  {
@@ -491,7 +491,7 @@
491
491
  "checked": false
492
492
  },
493
493
  {
494
- "text": "Dependencies: mermaid-ascii, markdown parser (mistune or markdown-it-py), Rich (for ANSI primitives)",
494
+ "text": "Dependencies: mistune (markdown parsing), grandalf (graph layout), pygments (syntax highlighting)",
495
495
  "checked": false
496
496
  },
497
497
  {
@@ -500,7 +500,7 @@
500
500
  }
501
501
  ],
502
502
  "status": "draft",
503
- "agentNotes": "Revised per review — Python package on PyPI instead of npm. mermaid-ascii is a direct dependency. Rich can be used for low-level ANSI output (colors, styles) without using its layout/panel system we build our own layout engine on top.",
503
+ "agentNotes": "Python package on PyPI. Mermaid diagrams render via termrender's own native parsers plus grandalf for graph layout — no external mermaid rendering dependency.",
504
504
  "userNotes": "User: 'It can be python — it doesn't need to be ts'",
505
505
  "questions": [
506
506
  {
@@ -53,7 +53,7 @@ Layout (`layout.py::resolve_height`, `BlockType.MERMAID` branch) precomputes `bl
53
53
 
54
54
  ## `mermaid_pie.py` — re-expressed as a bar chart, not a text circle
55
55
 
56
- A text pie chart is worse than a labeled bar chart at the same information density (per the ratified plan), so `parse_pie` extracts `(title, items)` from the `pie [showData]` / `title …` / `"label" : value` grammar and `render` hands the items to `charts.py::render_bar` via a synthetic `Block(type=BlockType.BAR, ...)` — no duplicated bar-drawing logic. Each item's `unit` attr is set to the pre-formatted percentage string (e.g. `" (40.0%)"`) so `render_bar`'s existing value-formatting appends it for free. Called with `color=False` always — mermaid output is monochrome by design (the attach viewer relies on `--color on == --color off`). Unparseable/empty input degrades to the raw source lines, same contract as the binary path.
56
+ A text pie chart is worse than a labeled bar chart at the same information density (per the ratified plan), so `parse_pie` extracts `(title, items)` from the `pie [showData]` / `title …` / `"label" : value` grammar and `render` hands the items to `charts.py::render_bar` via a synthetic `Block(type=BlockType.BAR, ...)` — no duplicated bar-drawing logic. Each item's `unit` attr is set to the pre-formatted percentage string (e.g. `" (40.0%)"`) so `render_bar`'s existing value-formatting appends it for free. Called with `color=False` always — mermaid output is monochrome by design (the attach viewer relies on `--color on == --color off`). Unparseable/empty input degrades to the raw source lines, same degradation contract every other mermaid renderer in this package upholds.
57
57
 
58
58
  ## `mermaid_gantt.py` — dates resolved via a running `last_end` cursor, not a strict grammar
59
59
 
@@ -65,7 +65,7 @@ A text pie chart is worse than a labeled bar chart at the same information densi
65
65
 
66
66
  ## `mermaid_sequence.py` — deterministic column layout, no graph-layout search
67
67
 
68
- 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.).
68
+ 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/class/state/ER renderers. 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.).
69
69
 
70
70
  ## `mermaid_mindmap.py` — mindmap source is already `tree.py`'s shape
71
71
 
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
-
7
5
  from termrender.blocks import Block
8
6
  from termrender.renderers import (
9
7
  mermaid_class,
@@ -17,18 +15,10 @@ from termrender.renderers import (
17
15
  mermaid_state,
18
16
  mermaid_timeline,
19
17
  )
18
+ from termrender.renderers.mermaid_degradation import raw_echo
20
19
  from termrender.renderers.mermaid_prelude import strip_prelude_lines
21
20
  from termrender.style import visual_ljust
22
21
 
23
- # Same box-drawing/geometric glyph ranges the native renderers (mermaid_flow,
24
- # mermaid_class, mermaid_state, mermaid_er) guard their own raw-echo paths
25
- # with, and the same ranges the downstream crouter viewer keys on to detect
26
- # render *success*. An exotic/unrecognized diagram type has no renderer at
27
- # all, so its degradation lives here rather than in a shared renderer module;
28
- # it must uphold the identical contract — no such glyph survives the echo,
29
- # even one present verbatim in the source itself.
30
- _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
31
-
32
22
 
33
23
  def _first_line_type(source: str) -> str:
34
24
  """Return the lowercased first real line's leading keyword.
@@ -52,21 +42,6 @@ def _stripped(source: str) -> str:
52
42
  return "\n".join(strip_prelude_lines(source.splitlines()))
53
43
 
54
44
 
55
- def _raw_echo(source: str) -> list[str]:
56
- """Degrade an exotic/unrecognized diagram type to its raw source lines.
57
-
58
- No renderer exists for these types (sankey, C4, gitgraph, block, packet,
59
- kanban, quadrantChart, …, and anything unrecognized): the source is
60
- echoed back verbatim, with box-drawing/geometric glyphs neutralized so
61
- the crouter viewer's "no glyphs survived" success check can't be fooled
62
- by one present in the raw source itself — the same guarantee every
63
- native renderer's own raw-echo path upholds.
64
- """
65
- return [
66
- _GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
67
- ]
68
-
69
-
70
45
  def render_mermaid_lines(source: str, width: int) -> list[str]:
71
46
  """Dispatch a mermaid source to its type's renderer and return raw lines.
72
47
 
@@ -76,8 +51,9 @@ def render_mermaid_lines(source: str, width: int) -> list[str]:
76
51
  ``classDiagram``, ``stateDiagram``/``stateDiagram-v2``, and ``erDiagram``.
77
52
  Any other type (sankey, C4, gitgraph, block, packet, kanban, …, or
78
53
  anything unrecognized) degrades to a raw echo of the source with no
79
- box-drawing glyphs — see :func:`_raw_echo`. Lines are returned unpadded;
80
- callers apply width padding uniformly.
54
+ box-drawing glyphs — see :func:`~termrender.renderers.mermaid_degradation.
55
+ raw_echo`. Lines are returned unpadded; callers apply width padding
56
+ uniformly.
81
57
  """
82
58
  diagram_type = _first_line_type(source)
83
59
  if diagram_type.startswith("pie"):
@@ -100,7 +76,7 @@ def render_mermaid_lines(source: str, width: int) -> list[str]:
100
76
  return mermaid_state.render_state(source, width)
101
77
  if diagram_type.startswith("erdiagram"):
102
78
  return mermaid_er.render_er(source, width)
103
- return _raw_echo(source)
79
+ return raw_echo(source)
104
80
 
105
81
 
106
82
  def render(block: Block, color: bool) -> list[str]:
@@ -1,10 +1,9 @@
1
1
  """Native renderer for mermaid ``classDiagram`` sources.
2
2
 
3
- Standalone module: exposes a single pure function, :func:`render_class`.
4
- Not wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned
5
- phase) nothing in this package imports it. Own parser, own tests, no
6
- dependency on ``Block`` or the render pipeline, matching the shape of
7
- ``mermaid_sequence.py`` and ``mermaid_flow.py``.
3
+ Wired into ``mermaid.py``'s dispatcher: exposes a single pure function,
4
+ :func:`render_class`. Own parser, own tests, no dependency on ``Block``
5
+ or the render pipeline, matching the shape of ``mermaid_sequence.py``
6
+ and ``mermaid_flow.py``.
8
7
 
9
8
  This module does no layout or rasterization of its own. It parses mermaid
10
9
  ``classDiagram`` source into the *same* :class:`~termrender.renderers.
@@ -53,7 +52,8 @@ Grammar supported
53
52
  centers this single combined label on the edge's longest straight run;
54
53
  the UML convention of a cardinality glyph pinned at each endpoint
55
54
  separately isn't supported (see *Known degradations*).
56
- - ``%%`` comments are dropped.
55
+ - ``%%`` comments and well-formed presentational directives (``classDef``/
56
+ ``style``/``cssClass``/``accTitle``/``accDescr``) are dropped.
57
57
 
58
58
  Never crashes: any unparseable input (missing header, or a parse/layout
59
59
  exception) degrades to a **raw echo** of the source lines with no
@@ -68,8 +68,9 @@ Known degradations (by design, not bugs)
68
68
  (per-endpoint cardinality glyphs, common in dedicated UML tools) — the
69
69
  underlying engine places one label per edge.
70
70
  - ``note`` statements, `<<...>>` generic constraints beyond the ``~T~``
71
- substitution, and class-diagram namespaces are not recognized — lines
72
- using them are silently dropped (best-effort parsing, never raised).
71
+ substitution, and class-diagram namespaces are not recognized; they
72
+ raise ``ClassDiagramError`` and the public renderer raw-echoes instead
73
+ of half-rendering the diagram.
73
74
  - See ``mermaid_flow_layout.py``'s docstring for the inherited engine
74
75
  degradations (dense-graph crossings, CJK wrap width, minimum-box-size
75
76
  cosmetics) — this renderer's output goes through the same rasterizer and
@@ -80,6 +81,7 @@ from __future__ import annotations
80
81
 
81
82
  import re
82
83
 
84
+ from termrender.renderers.mermaid_degradation import raw_echo
83
85
  from termrender.renderers.mermaid_flow_layout import layout_flowgraph
84
86
  from termrender.renderers.mermaid_flow_model import (
85
87
  Direction,
@@ -105,6 +107,21 @@ class ClassDiagramError(Exception):
105
107
  _HEADER_RE = re.compile(r"^classDiagram(?:-v2)?\b", re.IGNORECASE)
106
108
  _DIRECTION_RE = re.compile(r"^direction\s+(TB|TD|LR|RL|BT)\b", re.IGNORECASE)
107
109
  _COMMENT_RE = re.compile(r"^%%")
110
+ _CLASSDEF_RE = re.compile(r"^classDef\b\s+\S+\s+\S.*$", re.IGNORECASE)
111
+ _STYLE_RE = re.compile(r"^style\b\s+\S+\s+\S.*$", re.IGNORECASE)
112
+ _CSSCLASS_RE = re.compile(
113
+ r'^cssClass\b\s+(?:"[^"]+"|\S+)(?:\s*,\s*(?:"[^"]+"|\S+))*\s+\S+\s*$',
114
+ re.IGNORECASE,
115
+ )
116
+ _ACCTITLE_RE = re.compile(r"^accTitle\b\s*:?\s+\S.*$", re.IGNORECASE)
117
+ _ACCDESCR_RE = re.compile(r"^accDescr\b\s*:?\s+\S.*$", re.IGNORECASE)
118
+ _CLASS_ASSIGN_RE = re.compile(
119
+ r"^class\b\s+(?:\S+(?:\s*,\s*\S+)*)\s+(?!\{)\S+\s*$", re.IGNORECASE
120
+ )
121
+ _CLASS_SHORTHAND_RE = re.compile(r"^class\b\s+\S+:::\S+\s*$", re.IGNORECASE)
122
+ _BODY_DIRECTIVE_RE = re.compile(
123
+ r"^(?:classDef|class|style|cssClass|accTitle|accDescr)\b", re.IGNORECASE
124
+ )
108
125
 
109
126
  _CLASS_OPEN_RE = re.compile(r"^class\s+(\S+)\s*\{(.*)$")
110
127
  _CLASS_BARE_RE = re.compile(r"^class\s+(\S+)\s*$")
@@ -124,8 +141,6 @@ _REL_RIGHT_RE = re.compile(
124
141
  r'^\s*(?:"(?P<card>[^"]*)")?\s*(?P<id>[^\s":]+)\s*(?::\s*(?P<label>.*))?$'
125
142
  )
126
143
 
127
- _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
128
-
129
144
 
130
145
  class _ClassDef:
131
146
  """Mutable parse-time record for one UML class — converted to a
@@ -173,6 +188,31 @@ def _get_or_create(classes: dict[str, _ClassDef], token: str) -> _ClassDef:
173
188
  return cls
174
189
 
175
190
 
191
+ def _is_presentational_line(line: str) -> bool:
192
+ return any(
193
+ regex.match(line)
194
+ for regex in (
195
+ _CLASSDEF_RE,
196
+ _STYLE_RE,
197
+ _CSSCLASS_RE,
198
+ _ACCTITLE_RE,
199
+ _ACCDESCR_RE,
200
+ _CLASS_ASSIGN_RE,
201
+ _CLASS_SHORTHAND_RE,
202
+ )
203
+ )
204
+
205
+
206
+ def _consume_block_text(cls: _ClassDef, text: str) -> None:
207
+ for part in text.split(";"):
208
+ part = part.strip()
209
+ if part and _BODY_DIRECTIVE_RE.match(part):
210
+ raise ClassDiagramError(
211
+ f"unrecognized class diagram statement: {part!r}"
212
+ )
213
+ _consume_members(cls, text)
214
+
215
+
176
216
  def _consume_members(cls: _ClassDef, text: str) -> None:
177
217
  """Split ``text`` (one physical line, or the inline body of a
178
218
  ``class Name { ... }`` opener/closer) on ``;`` into individual member/
@@ -271,11 +311,15 @@ def _build_graph(source: str) -> FlowGraph:
271
311
  continue
272
312
 
273
313
  if block_cls is not None:
314
+ if _COMMENT_RE.match(line):
315
+ continue
274
316
  if line.endswith("}"):
275
- _consume_members(block_cls, line[:-1])
317
+ body = line[:-1].rstrip()
318
+ if body:
319
+ _consume_block_text(block_cls, body)
276
320
  block_cls = None
277
321
  else:
278
- _consume_members(block_cls, line)
322
+ _consume_block_text(block_cls, line)
279
323
  continue
280
324
 
281
325
  if not seen_header:
@@ -283,7 +327,7 @@ def _build_graph(source: str) -> FlowGraph:
283
327
  seen_header = True
284
328
  continue
285
329
 
286
- if _COMMENT_RE.match(line):
330
+ if _COMMENT_RE.match(line) or _is_presentational_line(line):
287
331
  continue
288
332
 
289
333
  dm = _DIRECTION_RE.match(line)
@@ -299,9 +343,9 @@ def _build_graph(source: str) -> FlowGraph:
299
343
  cls.has_block = True
300
344
  body = inline_body.rstrip()
301
345
  if body.endswith("}"):
302
- _consume_members(cls, body[:-1])
346
+ _consume_block_text(cls, body[:-1])
303
347
  else:
304
- _consume_members(cls, body)
348
+ _consume_block_text(cls, body)
305
349
  block_cls = cls
306
350
  continue
307
351
 
@@ -326,7 +370,13 @@ def _build_graph(source: str) -> FlowGraph:
326
370
  _consume_members(cls, mm.group("member"))
327
371
  continue
328
372
 
329
- # Unrecognized line: consumed silently, best-effort.
373
+ if _CLASS_ASSIGN_RE.match(line) or _CLASS_SHORTHAND_RE.match(line):
374
+ continue
375
+
376
+ raise ClassDiagramError(f"unrecognized class diagram statement: {line!r}")
377
+
378
+ if block_cls is not None:
379
+ raise ClassDiagramError("unterminated class body")
330
380
 
331
381
  if not seen_header:
332
382
  raise ClassDiagramError(
@@ -354,12 +404,6 @@ def _build_graph(source: str) -> FlowGraph:
354
404
  return FlowGraph(direction=direction, nodes=nodes, edges=edges, subgraphs=[])
355
405
 
356
406
 
357
- def _raw_echo(source: str) -> list[str]:
358
- return [
359
- _GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
360
- ]
361
-
362
-
363
407
  def render_class(source: str, width: int) -> list[str]:
364
408
  """Render a mermaid ``classDiagram`` source to unicode lines.
365
409
 
@@ -381,17 +425,17 @@ def render_class(source: str, width: int) -> list[str]:
381
425
  try:
382
426
  graph = _build_graph(source)
383
427
  except ClassDiagramError:
384
- return _raw_echo(source)
428
+ return raw_echo(source)
385
429
 
386
430
  if not graph.nodes:
387
- return _raw_echo(source)
431
+ return raw_echo(source)
388
432
 
389
433
  try:
390
434
  lines = layout_flowgraph(graph, width)
391
435
  except Exception:
392
- return _raw_echo(source)
436
+ return raw_echo(source)
393
437
 
394
438
  if not lines:
395
- return _raw_echo(source)
439
+ return raw_echo(source)
396
440
 
397
441
  return lines
@@ -0,0 +1,34 @@
1
+ """Shared raw-echo degradation contract for the native mermaid renderers.
2
+
3
+ Every native mermaid renderer in this package (``mermaid.py``'s exotic-type
4
+ fallback, and the flowchart-model-backed ``mermaid_flow.py``,
5
+ ``mermaid_class.py``, ``mermaid_state.py``, ``mermaid_er.py``) must degrade
6
+ identically on any input it cannot fully render under its supported
7
+ grammar: echo the source lines verbatim (``.rstrip()``-ed), with every
8
+ box-drawing/geometric glyph replaced by ``?``. The downstream crouter
9
+ attach viewer keys on the *presence* of one of these glyphs to decide
10
+ whether a native render succeeded and the original mermaid fence can be
11
+ dropped — so the echo path must never contain one, even when malformed or
12
+ degenerate source happens to contain a literal glyph of its own (e.g. a
13
+ hand-typed ``"\u250c"`` in otherwise-non-mermaid text).
14
+
15
+ :func:`raw_echo` is the one shared implementation of that contract; every
16
+ renderer module imports it rather than keeping its own copy of the glyph
17
+ regex and echo function.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ __all__ = ["GLYPH_RANGE_RE", "raw_echo"]
25
+
26
+ GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
27
+
28
+
29
+ def raw_echo(source: str) -> list[str]:
30
+ """Echo ``source``'s lines verbatim, minus trailing whitespace, with
31
+ every box-drawing/geometric glyph (:data:`GLYPH_RANGE_RE`) replaced by
32
+ ``?`` so the echo can never be mistaken for a successful native
33
+ render."""
34
+ return [GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]