termrender 4.1.1__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.
- {termrender-4.1.1 → termrender-4.3.0}/CHANGELOG.md +77 -0
- {termrender-4.1.1 → termrender-4.3.0}/PKG-INFO +1 -1
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/CLAUDE.md +21 -3
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/mermaid.py +29 -9
- termrender-4.3.0/src/termrender/renderers/mermaid_flow_model.py +127 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_journey.py +121 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_mindmap.py +99 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_prelude.py +55 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_sequence.py +600 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_timeline.py +118 -0
- termrender-4.3.0/tests/test_mermaid_dispatch.py +195 -0
- termrender-4.3.0/tests/test_mermaid_flow_model.py +113 -0
- termrender-4.3.0/tests/test_mermaid_journey.py +128 -0
- termrender-4.3.0/tests/test_mermaid_mindmap.py +115 -0
- termrender-4.3.0/tests/test_mermaid_sequence.py +463 -0
- termrender-4.3.0/tests/test_mermaid_timeline.py +107 -0
- termrender-4.1.1/tests/test_mermaid_dispatch.py +0 -89
- {termrender-4.1.1 → termrender-4.3.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/.gitignore +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/CLAUDE.md +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/LICENSE +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/README.md +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/design.json +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/pyproject.toml +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/requirements.json +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/__init__.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/__main__.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/blocks.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/emit.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/layout.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/parser.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/py.typed +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/src/termrender/style.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/__init__.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_charts.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_diff.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_linebreak.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_stat.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_tasklist.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_timeline.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.1.1 → termrender-4.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,83 @@
|
|
|
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
|
+
|
|
32
|
+
## v4.2.0 (2026-07-06)
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
- **mermaid-sequence**: Reject dash-adjacent punctuation as part of an id
|
|
37
|
+
([`612f36d`](https://github.com/crouton-labs/termrender/commit/612f36d14d68e6c51f4ac6d16f0ae837e44ddd01))
|
|
38
|
+
|
|
39
|
+
Reviewer found: near-miss arrow syntax with no sequence-diagram equivalent (e.g. flowchart-only
|
|
40
|
+
`A-.->B`, or garbage like `A==>>B`) was silently absorbed into the source id ("A-.") instead of
|
|
41
|
+
degrading to a plain line, because the arrow regex's identifier token was \S+/\S+? (anything
|
|
42
|
+
non-space) and would backtrack through dash/dot punctuation until some later '>' happened to
|
|
43
|
+
complete a valid-looking arrow.
|
|
44
|
+
|
|
45
|
+
Fix: identifiers now exclude '-' (plus ':', '+', whitespace) — dash is reserved for the arrow
|
|
46
|
+
itself, so an id is the maximal run up to the first dash, eliminating the backtracking ambiguity
|
|
47
|
+
entirely. Also documents the pre-existing (accepted, mirrors wrap_text()'s CJK bug elsewhere in
|
|
48
|
+
termrender) character-index vs visual-width limitation in the module docstring, per independent
|
|
49
|
+
review (non-blocking, not fixed here — same class of fix as the documented wrap_text() constraint,
|
|
50
|
+
out of proportion for a renderer not yet wired into the dispatcher).
|
|
51
|
+
|
|
52
|
+
Adds 2 regression tests for the repro cases. 191 tests pass (full suite, up from 189).
|
|
53
|
+
|
|
54
|
+
### Features
|
|
55
|
+
|
|
56
|
+
- Add native ASCII sequence-diagram renderer for mermaid
|
|
57
|
+
([`ad63977`](https://github.com/crouton-labs/termrender/commit/ad639774edcbcd744ba7f2183c4f5d8c83630af0))
|
|
58
|
+
|
|
59
|
+
Standalone module (src/termrender/renderers/mermaid_sequence.py) exposing render_sequence(source,
|
|
60
|
+
width) -> list[str]. Not wired into the mermaid.py dispatcher yet (a later integration phase owns
|
|
61
|
+
that).
|
|
62
|
+
|
|
63
|
+
Layout: participant boxes across the top/bottom, vertical lifelines, messages as horizontal arrows
|
|
64
|
+
in declaration order, self-messages as a loop-back. Column spacing is a 1D constraint pass driven
|
|
65
|
+
by participant label widths and the messages crossing each gap (no 2D layout search needed since
|
|
66
|
+
message order already fixes the vertical axis).
|
|
67
|
+
|
|
68
|
+
Grammar: participant/actor with 'as' aliases plus implicit participants from first use; all 8 arrow
|
|
69
|
+
forms (->, -->, ->>, -->>, -x, --x, -), --)) with distinct line style (solid/dashed) and arrowhead
|
|
70
|
+
glyph per marker; Note over/left of/right of; autonumber (start/step); loop/alt/opt/par/
|
|
71
|
+
critical/break/rect/box render as labeled separator bands (else/and/ option as mid-band
|
|
72
|
+
separators); activate/deactivate (including the +/- arrow shorthand) and <br/> are tolerated and
|
|
73
|
+
flattened; any other unrecognized or malformed line (stray else/end, unknown directives) degrades
|
|
74
|
+
to a plain text line rather than raising.
|
|
75
|
+
|
|
76
|
+
Tests: tests/test_mermaid_sequence.py, 40 cases covering golden output, all arrow variants, aliases,
|
|
77
|
+
implicit participant ordering, notes, autonumber, block nesting, degradation paths, and the
|
|
78
|
+
no-trailing-whitespace contract. Full suite (189 tests) still green.
|
|
79
|
+
|
|
80
|
+
|
|
4
81
|
## v4.1.1 (2026-07-06)
|
|
5
82
|
|
|
6
83
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
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
|
|
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
|
-
**`
|
|
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
|
-
**`
|
|
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
|
|
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
|
|
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``, …)
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
keeps today's exact path: the
|
|
162
|
-
|
|
163
|
-
unpadded; callers apply width
|
|
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:]
|