mdok 0.1.0__py3-none-any.whl
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.
- mdok/__init__.py +4 -0
- mdok/__main__.py +4 -0
- mdok/canvas.py +102 -0
- mdok/cli.py +169 -0
- mdok/renderer.py +632 -0
- mdok/theme.py +39 -0
- mdok-0.1.0.dist-info/METADATA +97 -0
- mdok-0.1.0.dist-info/RECORD +12 -0
- mdok-0.1.0.dist-info/WHEEL +5 -0
- mdok-0.1.0.dist-info/entry_points.txt +3 -0
- mdok-0.1.0.dist-info/licenses/LICENSE +21 -0
- mdok-0.1.0.dist-info/top_level.txt +1 -0
mdok/__init__.py
ADDED
mdok/__main__.py
ADDED
mdok/canvas.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
|
+
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
|
5
|
+
from rich.segment import Segment
|
|
6
|
+
from rich.measure import Measurement
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CanvasConfig:
|
|
11
|
+
"""Configuration for document canvas layout."""
|
|
12
|
+
max_width: Optional[int] = None
|
|
13
|
+
min_width: int = 70
|
|
14
|
+
cap_width: int = 124
|
|
15
|
+
ratio: float = 0.74
|
|
16
|
+
margin: Optional[int] = None
|
|
17
|
+
center: bool = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calculate_dimensions(
|
|
21
|
+
terminal_width: int,
|
|
22
|
+
config: Optional[CanvasConfig] = None,
|
|
23
|
+
) -> Tuple[int, int]:
|
|
24
|
+
"""
|
|
25
|
+
Calculate (doc_width, left_margin) based on terminal width and config.
|
|
26
|
+
|
|
27
|
+
Rules:
|
|
28
|
+
- Narrow screens (<= 82): maximize width with minimal 2-char gutter.
|
|
29
|
+
- Medium screens (83-105): generous 80-85% width.
|
|
30
|
+
- Wide screens (> 105): proportional document sheet (~74% width) up to cap_width (default 124),
|
|
31
|
+
avoiding both full-screen blowout and artificial narrow text walls.
|
|
32
|
+
"""
|
|
33
|
+
cfg = config or CanvasConfig()
|
|
34
|
+
|
|
35
|
+
if cfg.max_width is not None:
|
|
36
|
+
doc_w = min(cfg.max_width, terminal_width)
|
|
37
|
+
else:
|
|
38
|
+
if terminal_width <= 82:
|
|
39
|
+
doc_w = max(30, terminal_width - 4)
|
|
40
|
+
elif terminal_width <= 105:
|
|
41
|
+
doc_w = min(terminal_width - 6, max(76, int(terminal_width * 0.84)))
|
|
42
|
+
else:
|
|
43
|
+
proportional = int(terminal_width * cfg.ratio)
|
|
44
|
+
doc_w = min(cfg.cap_width, max(88, proportional))
|
|
45
|
+
doc_w = min(doc_w, terminal_width - 12)
|
|
46
|
+
|
|
47
|
+
doc_w = max(20, min(doc_w, terminal_width))
|
|
48
|
+
|
|
49
|
+
if cfg.margin is not None:
|
|
50
|
+
left_margin = max(0, min(cfg.margin, terminal_width - doc_w))
|
|
51
|
+
elif cfg.center:
|
|
52
|
+
left_margin = max(0, (terminal_width - doc_w) // 2)
|
|
53
|
+
else:
|
|
54
|
+
left_margin = 0
|
|
55
|
+
|
|
56
|
+
return doc_w, left_margin
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DocumentCanvas:
|
|
60
|
+
"""
|
|
61
|
+
Renders a renderable constrained to `doc_width` and indented by `left_margin`.
|
|
62
|
+
This provides a centered 'sheet of paper' reading experience.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
renderable: RenderableType,
|
|
68
|
+
doc_width: int,
|
|
69
|
+
left_margin: int,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.renderable = renderable
|
|
72
|
+
self.doc_width = doc_width
|
|
73
|
+
self.left_margin = left_margin
|
|
74
|
+
|
|
75
|
+
def __rich_console__(
|
|
76
|
+
self, console: Console, options: ConsoleOptions
|
|
77
|
+
) -> RenderResult:
|
|
78
|
+
if getattr(self.renderable, "has_canvas_layout", False):
|
|
79
|
+
# Renderable manages its own margins and widths (including table breakout)
|
|
80
|
+
yield from console.render(self.renderable, options)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Constrain the inner document to doc_width
|
|
84
|
+
doc_options = options.update_width(self.doc_width)
|
|
85
|
+
lines = console.render_lines(self.renderable, doc_options, pad=False)
|
|
86
|
+
|
|
87
|
+
margin_prefix = " " * self.left_margin if self.left_margin > 0 else ""
|
|
88
|
+
margin_segment = Segment(margin_prefix) if margin_prefix else None
|
|
89
|
+
|
|
90
|
+
for line in lines:
|
|
91
|
+
if margin_segment and line:
|
|
92
|
+
yield margin_segment
|
|
93
|
+
for segment in line:
|
|
94
|
+
yield segment
|
|
95
|
+
yield Segment.line()
|
|
96
|
+
elif margin_segment and not line:
|
|
97
|
+
# Blank line
|
|
98
|
+
yield Segment.line()
|
|
99
|
+
else:
|
|
100
|
+
for segment in line:
|
|
101
|
+
yield segment
|
|
102
|
+
yield Segment.line()
|
mdok/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.theme import Theme
|
|
8
|
+
|
|
9
|
+
from mdok import __version__
|
|
10
|
+
from mdok.theme import DEFAULT_THEME, BOX_STYLES
|
|
11
|
+
from mdok.canvas import CanvasConfig, calculate_dimensions, DocumentCanvas
|
|
12
|
+
from mdok.renderer import DocMarkdown
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="mdok",
|
|
18
|
+
description="Document-grade Markdown Reader for the terminal.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"file",
|
|
22
|
+
nargs="?",
|
|
23
|
+
default="-",
|
|
24
|
+
help="Markdown file to read, or '-' for standard input (default: '-')",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-w",
|
|
28
|
+
"--width",
|
|
29
|
+
type=int,
|
|
30
|
+
default=None,
|
|
31
|
+
help="Max document reading width (default: auto ~88 chars on wide displays)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"-m",
|
|
35
|
+
"--margin",
|
|
36
|
+
type=int,
|
|
37
|
+
default=None,
|
|
38
|
+
help="Explicit left margin in columns (default: auto-centered)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--no-center",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Disable centering and align to left edge",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-b",
|
|
47
|
+
"--box",
|
|
48
|
+
choices=list(BOX_STYLES.keys()),
|
|
49
|
+
default="booktabs",
|
|
50
|
+
help="Table box style: booktabs (default), minimal, rounded, simple, simple_head, none",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--no-lines",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Hide horizontal row divider lines in tables",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--code-theme",
|
|
59
|
+
default="nord",
|
|
60
|
+
help="Pygments code syntax highlighting theme (default: nord)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-p",
|
|
64
|
+
"--pager",
|
|
65
|
+
action="store_true",
|
|
66
|
+
default=None,
|
|
67
|
+
help="Force output through pager (less -RFX)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--no-pager",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Disable pager and print directly to terminal",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"-v",
|
|
76
|
+
"--version",
|
|
77
|
+
action="version",
|
|
78
|
+
version=f"%(prog)s {__version__}",
|
|
79
|
+
)
|
|
80
|
+
return parser.parse_args(args)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_content(file_arg: str) -> str:
|
|
84
|
+
if file_arg == "-":
|
|
85
|
+
if sys.stdin.isatty():
|
|
86
|
+
# Nothing piped and stdin is a tty
|
|
87
|
+
return ""
|
|
88
|
+
return sys.stdin.read()
|
|
89
|
+
|
|
90
|
+
path = os.path.expanduser(file_arg)
|
|
91
|
+
if not os.path.exists(path):
|
|
92
|
+
sys.stderr.write(f"Error: file not found: {file_arg}\n")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
96
|
+
return f.read()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
100
|
+
args = parse_args(argv)
|
|
101
|
+
|
|
102
|
+
content = read_content(args.file)
|
|
103
|
+
if not content:
|
|
104
|
+
if args.file == "-":
|
|
105
|
+
sys.stderr.write("mdok: no input provided via stdin or file. Use 'mdok --help' for usage.\n")
|
|
106
|
+
return 1
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
# Determine terminal dimensions
|
|
110
|
+
is_tty = sys.stdout.isatty()
|
|
111
|
+
term_width, term_height = shutil.get_terminal_size(fallback=(100, 24))
|
|
112
|
+
|
|
113
|
+
# Canvas layout calculations
|
|
114
|
+
config = CanvasConfig(
|
|
115
|
+
max_width=args.width,
|
|
116
|
+
margin=args.margin,
|
|
117
|
+
center=not args.no_center if is_tty else False,
|
|
118
|
+
)
|
|
119
|
+
doc_width, left_margin = calculate_dimensions(term_width, config)
|
|
120
|
+
|
|
121
|
+
# If piped to another process (not a tty) and user didn't specify width/margin,
|
|
122
|
+
# don't pad with left margin
|
|
123
|
+
if not is_tty and args.margin is None and not args.no_center:
|
|
124
|
+
left_margin = 0
|
|
125
|
+
|
|
126
|
+
# Build document
|
|
127
|
+
markdown_doc = DocMarkdown(
|
|
128
|
+
content,
|
|
129
|
+
code_theme=args.code_theme,
|
|
130
|
+
box_style=args.box,
|
|
131
|
+
show_lines=not args.no_lines,
|
|
132
|
+
doc_width=doc_width,
|
|
133
|
+
left_margin=left_margin,
|
|
134
|
+
term_width=term_width,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
canvas = DocumentCanvas(
|
|
138
|
+
renderable=markdown_doc,
|
|
139
|
+
doc_width=doc_width,
|
|
140
|
+
left_margin=left_margin,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
console = Console(theme=DEFAULT_THEME, width=term_width)
|
|
144
|
+
|
|
145
|
+
# Determine whether to page
|
|
146
|
+
use_pager = False
|
|
147
|
+
if is_tty:
|
|
148
|
+
if args.no_pager:
|
|
149
|
+
use_pager = False
|
|
150
|
+
elif args.pager:
|
|
151
|
+
use_pager = True
|
|
152
|
+
else:
|
|
153
|
+
# Auto-pager heuristic: estimate rendered line count
|
|
154
|
+
# Measure rendered lines
|
|
155
|
+
line_count = sum(1 for _ in console.render_lines(canvas, console.options))
|
|
156
|
+
if line_count > term_height - 2:
|
|
157
|
+
use_pager = True
|
|
158
|
+
|
|
159
|
+
if use_pager:
|
|
160
|
+
with console.pager(styles=True):
|
|
161
|
+
console.print(canvas)
|
|
162
|
+
else:
|
|
163
|
+
console.print(canvas)
|
|
164
|
+
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
sys.exit(main())
|
mdok/renderer.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import ClassVar, Dict, List, Optional, Tuple, Union
|
|
3
|
+
from rich.box import Box
|
|
4
|
+
from rich.cells import cell_len
|
|
5
|
+
from rich.console import Console, ConsoleOptions, RenderResult, Group
|
|
6
|
+
from rich.markdown import (
|
|
7
|
+
Markdown,
|
|
8
|
+
MarkdownContext,
|
|
9
|
+
MarkdownElement,
|
|
10
|
+
MarkdownIt,
|
|
11
|
+
TableBodyElement,
|
|
12
|
+
TableHeaderElement,
|
|
13
|
+
TableRowElement,
|
|
14
|
+
TableDataElement,
|
|
15
|
+
Paragraph,
|
|
16
|
+
BlockQuote,
|
|
17
|
+
Heading,
|
|
18
|
+
Link,
|
|
19
|
+
UnknownElement,
|
|
20
|
+
)
|
|
21
|
+
from rich.segment import Segment
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.text import Text, TextType
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.style import Style
|
|
26
|
+
|
|
27
|
+
from mdok.theme import BOOKTABS, BOX_STYLES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def calculate_smart_column_widths(
|
|
31
|
+
headers: List[str],
|
|
32
|
+
rows: List[List[str]],
|
|
33
|
+
available_width: int,
|
|
34
|
+
) -> List[int]:
|
|
35
|
+
"""
|
|
36
|
+
Calculates balanced column widths using non-linear volume-weighted allocation.
|
|
37
|
+
|
|
38
|
+
- Every column is guaranteed at least its longest unbreakable token + padding
|
|
39
|
+
(atomic badge & token preservation).
|
|
40
|
+
- Unwrapped ideal widths are respected (tables that fit stay compact).
|
|
41
|
+
- Surplus space is distributed via a continuous utility function weighted by
|
|
42
|
+
information volume (total text / rows), ensuring prose/description columns
|
|
43
|
+
receive comfortable reading widths without inflating short token columns.
|
|
44
|
+
"""
|
|
45
|
+
col_count = len(headers)
|
|
46
|
+
if col_count == 0:
|
|
47
|
+
return []
|
|
48
|
+
if col_count == 1:
|
|
49
|
+
return [max(3, available_width)]
|
|
50
|
+
|
|
51
|
+
min_widths = [0] * col_count
|
|
52
|
+
ideal_widths = [0] * col_count
|
|
53
|
+
total_lens = [0] * col_count
|
|
54
|
+
|
|
55
|
+
num_rows = max(1, len(rows))
|
|
56
|
+
|
|
57
|
+
badge_pattern = re.compile(r"^([\u2600-\u27bf\U0001f300-\U0001f9ff]\s*\S+)")
|
|
58
|
+
|
|
59
|
+
def _token_min(word: str) -> int:
|
|
60
|
+
subparts = [p for p in re.split(r"[-/]", word) if p]
|
|
61
|
+
if subparts:
|
|
62
|
+
return max(cell_len(p) for p in subparts)
|
|
63
|
+
return cell_len(word)
|
|
64
|
+
|
|
65
|
+
for c in range(col_count):
|
|
66
|
+
h_text = headers[c] if c < len(headers) else ""
|
|
67
|
+
body_cells = [r[c] if c < len(r) else "" for r in rows]
|
|
68
|
+
|
|
69
|
+
b_min = 2
|
|
70
|
+
badge_min = 0
|
|
71
|
+
body_ideal = 0
|
|
72
|
+
body_tot = 0
|
|
73
|
+
has_body_content = False
|
|
74
|
+
|
|
75
|
+
# Measure body cells: body cells define ideal width, volume, and primary min-content
|
|
76
|
+
for cell in body_cells:
|
|
77
|
+
plain = str(cell)
|
|
78
|
+
cell_length = cell_len(plain)
|
|
79
|
+
if cell_length > 0:
|
|
80
|
+
has_body_content = True
|
|
81
|
+
body_tot += cell_length
|
|
82
|
+
lines = plain.split("\n")
|
|
83
|
+
for line in lines:
|
|
84
|
+
body_ideal = max(body_ideal, cell_len(line))
|
|
85
|
+
badge_m = badge_pattern.match(line.strip())
|
|
86
|
+
if badge_m:
|
|
87
|
+
badge_min = max(badge_min, cell_len(badge_m.group(1)))
|
|
88
|
+
for word in line.split():
|
|
89
|
+
# Token min length, capped at 13 so code signatures don't starve other columns
|
|
90
|
+
b_min = max(b_min, min(_token_min(word), 13))
|
|
91
|
+
|
|
92
|
+
# Measure header: header sets wrap boundary, but does not dictate width
|
|
93
|
+
# if body content is sparse (headers can wrap / fold)
|
|
94
|
+
h_min = 2
|
|
95
|
+
h_ideal = 0
|
|
96
|
+
h_plain = str(h_text)
|
|
97
|
+
for line in h_plain.split("\n"):
|
|
98
|
+
h_ideal = max(h_ideal, cell_len(line))
|
|
99
|
+
badge_m = badge_pattern.match(line.strip())
|
|
100
|
+
if badge_m:
|
|
101
|
+
badge_min = max(badge_min, cell_len(badge_m.group(1)))
|
|
102
|
+
for word in line.split():
|
|
103
|
+
h_min = max(h_min, min(_token_min(word), 13))
|
|
104
|
+
|
|
105
|
+
if has_body_content:
|
|
106
|
+
# Atomic badges must never be split. Body tokens and header tokens set min width.
|
|
107
|
+
# Long header tokens fold if body is sparse.
|
|
108
|
+
col_min = max(badge_min, b_min, h_min)
|
|
109
|
+
min_widths[c] = col_min
|
|
110
|
+
ideal_widths[c] = max(min_widths[c], body_ideal, h_ideal)
|
|
111
|
+
total_lens[c] = body_tot
|
|
112
|
+
else:
|
|
113
|
+
col_min = max(badge_min, h_min, 3)
|
|
114
|
+
min_widths[c] = col_min
|
|
115
|
+
ideal_widths[c] = max(min_widths[c], h_ideal)
|
|
116
|
+
total_lens[c] = cell_len(h_plain)
|
|
117
|
+
|
|
118
|
+
total_ideal = sum(ideal_widths)
|
|
119
|
+
if total_ideal <= available_width:
|
|
120
|
+
return ideal_widths
|
|
121
|
+
|
|
122
|
+
sum_min = sum(min_widths)
|
|
123
|
+
if sum_min >= available_width:
|
|
124
|
+
deficit = sum_min - available_width
|
|
125
|
+
allocated = list(min_widths)
|
|
126
|
+
while deficit > 0:
|
|
127
|
+
candidates = [c for c in range(col_count) if allocated[c] > 3]
|
|
128
|
+
if not candidates:
|
|
129
|
+
break
|
|
130
|
+
best_c = max(candidates, key=lambda c: (allocated[c], -c))
|
|
131
|
+
allocated[best_c] -= 1
|
|
132
|
+
deficit -= 1
|
|
133
|
+
return allocated
|
|
134
|
+
|
|
135
|
+
surplus = available_width - sum_min
|
|
136
|
+
extra_wanted = [ideal_widths[c] - min_widths[c] for c in range(col_count)]
|
|
137
|
+
|
|
138
|
+
# Continuous volume-weighted utility function
|
|
139
|
+
# Columns with high text volume (narratives, descriptions) receive the lion's share
|
|
140
|
+
# of surplus width, while short token columns remain compact.
|
|
141
|
+
weights = []
|
|
142
|
+
for c in range(col_count):
|
|
143
|
+
if extra_wanted[c] <= 0:
|
|
144
|
+
weights.append(0.0)
|
|
145
|
+
else:
|
|
146
|
+
avg_vol = total_lens[c] / (num_rows + 1)
|
|
147
|
+
weights.append(extra_wanted[c] * (avg_vol ** 1.5))
|
|
148
|
+
|
|
149
|
+
sum_weights = sum(weights) or 1.0
|
|
150
|
+
|
|
151
|
+
allocated = list(min_widths)
|
|
152
|
+
for c in range(col_count):
|
|
153
|
+
if weights[c] > 0:
|
|
154
|
+
share = int(surplus * (weights[c] / sum_weights))
|
|
155
|
+
allocated[c] += min(share, extra_wanted[c])
|
|
156
|
+
|
|
157
|
+
remainder = available_width - sum(allocated)
|
|
158
|
+
while remainder > 0:
|
|
159
|
+
candidates = [
|
|
160
|
+
(total_lens[c], extra_wanted[c] - (allocated[c] - min_widths[c]), c)
|
|
161
|
+
for c in range(col_count)
|
|
162
|
+
if (allocated[c] - min_widths[c]) < extra_wanted[c]
|
|
163
|
+
]
|
|
164
|
+
if not candidates:
|
|
165
|
+
best_c = max(range(col_count), key=lambda c: total_lens[c])
|
|
166
|
+
allocated[best_c] += remainder
|
|
167
|
+
break
|
|
168
|
+
candidates.sort(reverse=True)
|
|
169
|
+
_, can_add, best_c = candidates[0]
|
|
170
|
+
step = min(remainder, max(1, can_add))
|
|
171
|
+
allocated[best_c] += step
|
|
172
|
+
remainder -= step
|
|
173
|
+
|
|
174
|
+
diff = available_width - sum(allocated)
|
|
175
|
+
if diff != 0:
|
|
176
|
+
best_c = max(range(col_count), key=lambda c: total_lens[c])
|
|
177
|
+
allocated[best_c] = max(3, allocated[best_c] + diff)
|
|
178
|
+
|
|
179
|
+
return allocated
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class DocTableElement(MarkdownElement):
|
|
183
|
+
"""
|
|
184
|
+
Enhanced Table element with:
|
|
185
|
+
- Clean Booktabs borders (or configurable box style)
|
|
186
|
+
- Row separation lines to prevent multi-line rows fusing together
|
|
187
|
+
- Respected column alignments (left, center, right)
|
|
188
|
+
- Soft word wrapping without breaking layout
|
|
189
|
+
- Smart column width budgeting (wide tables don't vertically collapse)
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
box_style: ClassVar[Box] = BOOKTABS
|
|
193
|
+
show_lines: ClassVar[bool] = True
|
|
194
|
+
|
|
195
|
+
def __init__(self) -> None:
|
|
196
|
+
self.header: Optional[TableHeaderElement] = None
|
|
197
|
+
self.body: Optional[TableBodyElement] = None
|
|
198
|
+
|
|
199
|
+
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
|
|
200
|
+
if isinstance(child, TableHeaderElement):
|
|
201
|
+
self.header = child
|
|
202
|
+
elif isinstance(child, TableBodyElement):
|
|
203
|
+
self.body = child
|
|
204
|
+
else:
|
|
205
|
+
raise RuntimeError(f"Unexpected child in table: {type(child)}")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def get_column_data(self) -> Tuple[List[str], List[List[str]]]:
|
|
209
|
+
headers: List[str] = []
|
|
210
|
+
if self.header is not None and self.header.row is not None:
|
|
211
|
+
for cell in self.header.row.cells:
|
|
212
|
+
headers.append(cell.content.plain)
|
|
213
|
+
elif self.body is not None and self.body.rows:
|
|
214
|
+
headers = [f"Col {i+1}" for i in range(len(self.body.rows[0].cells))]
|
|
215
|
+
|
|
216
|
+
rows: List[List[str]] = []
|
|
217
|
+
if self.body is not None:
|
|
218
|
+
for row in self.body.rows:
|
|
219
|
+
rows.append([cell.content.plain for cell in row.cells])
|
|
220
|
+
return headers, rows
|
|
221
|
+
|
|
222
|
+
def get_ideal_width(self) -> int:
|
|
223
|
+
headers, rows = self.get_column_data()
|
|
224
|
+
col_count = len(headers)
|
|
225
|
+
if col_count == 0:
|
|
226
|
+
return 0
|
|
227
|
+
ideal_widths = [0] * col_count
|
|
228
|
+
for c in range(col_count):
|
|
229
|
+
h_text = headers[c] if c < len(headers) else ""
|
|
230
|
+
all_cells = [h_text] + [r[c] if c < len(r) else "" for r in rows]
|
|
231
|
+
c_ideal = 2
|
|
232
|
+
for cell in all_cells:
|
|
233
|
+
plain = str(cell)
|
|
234
|
+
for line in plain.split("\n"):
|
|
235
|
+
c_ideal = max(c_ideal, cell_len(line))
|
|
236
|
+
ideal_widths[c] = c_ideal + 2
|
|
237
|
+
extra = max(0, col_count - 1)
|
|
238
|
+
return sum(ideal_widths) + extra
|
|
239
|
+
|
|
240
|
+
def __rich_console__(
|
|
241
|
+
self, console: Console, options: ConsoleOptions
|
|
242
|
+
) -> RenderResult:
|
|
243
|
+
table = Table(
|
|
244
|
+
box=self.box_style,
|
|
245
|
+
show_lines=self.show_lines,
|
|
246
|
+
pad_edge=False,
|
|
247
|
+
style="markdown.table.border",
|
|
248
|
+
show_edge=False,
|
|
249
|
+
collapse_padding=False,
|
|
250
|
+
padding=(0, 1),
|
|
251
|
+
expand=False,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
headers, rows = self.get_column_data()
|
|
255
|
+
column_count = len(headers)
|
|
256
|
+
|
|
257
|
+
if self.header is not None and self.header.row is not None:
|
|
258
|
+
for cell in self.header.row.cells:
|
|
259
|
+
heading = cell.content.copy()
|
|
260
|
+
heading.stylize("markdown.table.header")
|
|
261
|
+
justify = getattr(cell, "justify", "left")
|
|
262
|
+
table.add_column(
|
|
263
|
+
heading,
|
|
264
|
+
justify=justify,
|
|
265
|
+
overflow="fold",
|
|
266
|
+
no_wrap=False,
|
|
267
|
+
)
|
|
268
|
+
elif self.body is not None and self.body.rows:
|
|
269
|
+
for cell in self.body.rows[0].cells:
|
|
270
|
+
justify = getattr(cell, "justify", "left")
|
|
271
|
+
table.add_column(justify=justify, overflow="fold", no_wrap=False)
|
|
272
|
+
|
|
273
|
+
if self.body is not None:
|
|
274
|
+
for row in self.body.rows:
|
|
275
|
+
row_content = [cell.content for cell in row.cells]
|
|
276
|
+
while len(row_content) < column_count:
|
|
277
|
+
row_content.append(Text(""))
|
|
278
|
+
table.add_row(*row_content[:column_count])
|
|
279
|
+
|
|
280
|
+
# Apply smart column widths
|
|
281
|
+
if column_count > 0:
|
|
282
|
+
extra = table._extra_width
|
|
283
|
+
total_padding = sum(table._get_padding_width(i) for i in range(column_count))
|
|
284
|
+
avail = max(column_count * 3, options.max_width - extra - total_padding)
|
|
285
|
+
col_widths = calculate_smart_column_widths(headers, rows, avail)
|
|
286
|
+
for col, w in zip(table.columns, col_widths):
|
|
287
|
+
col.width = w
|
|
288
|
+
|
|
289
|
+
yield Text("") # Spacing before table
|
|
290
|
+
yield table
|
|
291
|
+
yield Text("") # Spacing after table
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
BR_PATTERN = re.compile(r"(?i)<br\s*/?>")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class DocTableDataElement(TableDataElement):
|
|
298
|
+
"""
|
|
299
|
+
Table data cell supporting <br>, <br/>, <br /> HTML line breaks
|
|
300
|
+
inside table cells.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def on_text(self, context: MarkdownContext, text: TextType) -> None:
|
|
304
|
+
code_style = context.console.get_style("markdown.code", default=None)
|
|
305
|
+
is_code = code_style is not None and context.current_style == code_style
|
|
306
|
+
if isinstance(text, str) and not is_code:
|
|
307
|
+
parts = BR_PATTERN.split(text)
|
|
308
|
+
if len(parts) > 1:
|
|
309
|
+
for i, part in enumerate(parts):
|
|
310
|
+
if i > 0:
|
|
311
|
+
self.content.append("\n")
|
|
312
|
+
if part:
|
|
313
|
+
self.content.append(part, context.current_style)
|
|
314
|
+
return
|
|
315
|
+
self.content.append(text, context.current_style)
|
|
316
|
+
elif isinstance(text, str):
|
|
317
|
+
self.content.append(text, context.current_style)
|
|
318
|
+
else:
|
|
319
|
+
self.content.append_text(text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class DocParagraph(Paragraph):
|
|
323
|
+
"""
|
|
324
|
+
Paragraph supporting <br>, <br/>, <br /> HTML line breaks.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def on_text(self, context: MarkdownContext, text: TextType) -> None:
|
|
328
|
+
code_style = context.console.get_style("markdown.code", default=None)
|
|
329
|
+
is_code = code_style is not None and context.current_style == code_style
|
|
330
|
+
if isinstance(text, str) and not is_code:
|
|
331
|
+
parts = BR_PATTERN.split(text)
|
|
332
|
+
if len(parts) > 1:
|
|
333
|
+
for i, part in enumerate(parts):
|
|
334
|
+
if i > 0:
|
|
335
|
+
self.text.append("\n")
|
|
336
|
+
if part:
|
|
337
|
+
self.text.append(part, context.current_style)
|
|
338
|
+
return
|
|
339
|
+
self.text.append(text, context.current_style)
|
|
340
|
+
elif isinstance(text, str):
|
|
341
|
+
self.text.append(text, context.current_style)
|
|
342
|
+
else:
|
|
343
|
+
self.text.append_text(text)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
ALERT_TYPES = {
|
|
349
|
+
"[!NOTE]": ("NOTE", "cyan", "ℹ"),
|
|
350
|
+
"[!TIP]": ("TIP", "green", "💡"),
|
|
351
|
+
"[!IMPORTANT]": ("IMPORTANT", "magenta", "★"),
|
|
352
|
+
"[!WARNING]": ("WARNING", "#e0af68", "⚠"),
|
|
353
|
+
"[!CAUTION]": ("CAUTION", "red", "✖"),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class DocBlockQuote(BlockQuote):
|
|
359
|
+
"""
|
|
360
|
+
Enhanced blockquote supporting GitHub-style alert callouts:
|
|
361
|
+
> [!NOTE]
|
|
362
|
+
> [!TIP]
|
|
363
|
+
> [!WARNING]
|
|
364
|
+
> [!IMPORTANT]
|
|
365
|
+
> [!CAUTION]
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def __rich_console__(
|
|
369
|
+
self, console: Console, options: ConsoleOptions
|
|
370
|
+
) -> RenderResult:
|
|
371
|
+
# Check if blockquote starts with an alert marker
|
|
372
|
+
raw_text = ""
|
|
373
|
+
for el in self.elements:
|
|
374
|
+
if hasattr(el, "text"):
|
|
375
|
+
raw_text = str(el.text).strip()
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
first_word = raw_text.split()[0].upper() if raw_text else ""
|
|
379
|
+
if first_word in ALERT_TYPES:
|
|
380
|
+
label, color, icon = ALERT_TYPES[first_word]
|
|
381
|
+
# Strip the marker prefix from the first element's text
|
|
382
|
+
for el in self.elements:
|
|
383
|
+
if hasattr(el, "text") and hasattr(el.text, "plain"):
|
|
384
|
+
idx = el.text.plain.upper().find(first_word)
|
|
385
|
+
if idx != -1:
|
|
386
|
+
cut = idx + len(first_word)
|
|
387
|
+
# Slice text past marker and strip leading space
|
|
388
|
+
stripped_plain = el.text.plain[cut:].lstrip()
|
|
389
|
+
el.text = Text(stripped_plain)
|
|
390
|
+
break
|
|
391
|
+
|
|
392
|
+
content_renderables = list(self.elements)
|
|
393
|
+
panel = Panel(
|
|
394
|
+
Group(*content_renderables),
|
|
395
|
+
title=f"[{color} bold]{icon} {label}[/]",
|
|
396
|
+
title_align="left",
|
|
397
|
+
border_style=f"dim {color}",
|
|
398
|
+
padding=(0, 1),
|
|
399
|
+
)
|
|
400
|
+
yield panel
|
|
401
|
+
else:
|
|
402
|
+
# Fall back to standard blockquote rendering
|
|
403
|
+
yield from super().__rich_console__(console, options)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class DocMarkdown(Markdown):
|
|
407
|
+
"""
|
|
408
|
+
Markdown renderer configured with document-grade elements and smart wide table breakout.
|
|
409
|
+
"""
|
|
410
|
+
elements = Markdown.elements.copy()
|
|
411
|
+
elements["table_open"] = DocTableElement
|
|
412
|
+
elements["td_open"] = DocTableDataElement
|
|
413
|
+
elements["th_open"] = DocTableDataElement
|
|
414
|
+
elements["paragraph_open"] = DocParagraph
|
|
415
|
+
elements["blockquote_open"] = DocBlockQuote
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
markup: str,
|
|
420
|
+
code_theme: str = "nord",
|
|
421
|
+
box_style: str = "booktabs",
|
|
422
|
+
show_lines: bool = True,
|
|
423
|
+
doc_width: Optional[int] = None,
|
|
424
|
+
left_margin: int = 0,
|
|
425
|
+
term_width: Optional[int] = None,
|
|
426
|
+
**kwargs,
|
|
427
|
+
) -> None:
|
|
428
|
+
super().__init__(markup, code_theme=code_theme, **kwargs)
|
|
429
|
+
# Parse with html_inline and html_block disabled so technical tags
|
|
430
|
+
# (like C++ templates <Tag> or XML tags <HAS-SETTER>) are preserved as plain text
|
|
431
|
+
parser = (
|
|
432
|
+
MarkdownIt()
|
|
433
|
+
.enable("strikethrough")
|
|
434
|
+
.enable("table")
|
|
435
|
+
.disable("html_inline", "html_block")
|
|
436
|
+
)
|
|
437
|
+
self.parsed = parser.parse(markup)
|
|
438
|
+
|
|
439
|
+
# Configure table box style and row separation
|
|
440
|
+
selected_box = BOX_STYLES.get(box_style, BOOKTABS)
|
|
441
|
+
DocTableElement.box_style = selected_box
|
|
442
|
+
DocTableElement.show_lines = show_lines
|
|
443
|
+
|
|
444
|
+
self.doc_width = doc_width
|
|
445
|
+
self.left_margin = left_margin
|
|
446
|
+
self.term_width = term_width or doc_width
|
|
447
|
+
self.has_canvas_layout = doc_width is not None
|
|
448
|
+
|
|
449
|
+
def _render_top_element(
|
|
450
|
+
self,
|
|
451
|
+
console: Console,
|
|
452
|
+
options: ConsoleOptions,
|
|
453
|
+
element: MarkdownElement,
|
|
454
|
+
need_new_line: bool,
|
|
455
|
+
new_line_segment: Segment,
|
|
456
|
+
) -> RenderResult:
|
|
457
|
+
if need_new_line:
|
|
458
|
+
yield new_line_segment
|
|
459
|
+
|
|
460
|
+
# Check for table breakout
|
|
461
|
+
if isinstance(element, DocTableElement) and self.doc_width is not None:
|
|
462
|
+
ideal_w = element.get_ideal_width()
|
|
463
|
+
if ideal_w > self.doc_width:
|
|
464
|
+
# Table wants more width than the standard document column.
|
|
465
|
+
# Allow a moderate breakout to prevent suffocated columns, but strictly
|
|
466
|
+
# cap it to a human-readable width (max 136 cols) to avoid unreadable
|
|
467
|
+
# edge-to-edge blowout across ultrawide monitors.
|
|
468
|
+
term_w = self.term_width or self.doc_width
|
|
469
|
+
readable_table_cap = max(self.doc_width, 136)
|
|
470
|
+
max_table_w = min(readable_table_cap, max(self.doc_width, term_w - 6))
|
|
471
|
+
target_w = min(ideal_w, max_table_w)
|
|
472
|
+
target_margin = max(0, (term_w - target_w) // 2)
|
|
473
|
+
elem_options = options.update_width(target_w)
|
|
474
|
+
elem_margin = target_margin
|
|
475
|
+
else:
|
|
476
|
+
elem_options = options.update_width(self.doc_width)
|
|
477
|
+
elem_margin = self.left_margin
|
|
478
|
+
elif self.doc_width is not None:
|
|
479
|
+
elem_options = options.update_width(self.doc_width)
|
|
480
|
+
elem_margin = self.left_margin
|
|
481
|
+
else:
|
|
482
|
+
elem_options = options
|
|
483
|
+
elem_margin = 0
|
|
484
|
+
|
|
485
|
+
lines = console.render_lines(element, elem_options, pad=False)
|
|
486
|
+
margin_prefix = " " * elem_margin if elem_margin > 0 else ""
|
|
487
|
+
margin_segment = Segment(margin_prefix) if margin_prefix else None
|
|
488
|
+
|
|
489
|
+
for line in lines:
|
|
490
|
+
if margin_segment and line:
|
|
491
|
+
yield margin_segment
|
|
492
|
+
for seg in line:
|
|
493
|
+
yield seg
|
|
494
|
+
yield Segment.line()
|
|
495
|
+
elif not line:
|
|
496
|
+
yield Segment.line()
|
|
497
|
+
else:
|
|
498
|
+
for seg in line:
|
|
499
|
+
yield seg
|
|
500
|
+
yield Segment.line()
|
|
501
|
+
|
|
502
|
+
def __rich_console__(
|
|
503
|
+
self, console: Console, options: ConsoleOptions
|
|
504
|
+
) -> RenderResult:
|
|
505
|
+
"""Render markdown to the console, with breakout support for wide tables."""
|
|
506
|
+
if not self.has_canvas_layout:
|
|
507
|
+
yield from super().__rich_console__(console, options)
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
style = console.get_style(self.style, default="none")
|
|
511
|
+
options = options.update(height=None)
|
|
512
|
+
context = MarkdownContext(
|
|
513
|
+
console,
|
|
514
|
+
options,
|
|
515
|
+
style,
|
|
516
|
+
inline_code_lexer=self.inline_code_lexer,
|
|
517
|
+
inline_code_theme=self.inline_code_theme,
|
|
518
|
+
)
|
|
519
|
+
tokens = self.parsed
|
|
520
|
+
inline_style_tags = self.inlines
|
|
521
|
+
new_line = False
|
|
522
|
+
_new_line_segment = Segment.line()
|
|
523
|
+
|
|
524
|
+
for token in self._flatten_tokens(tokens):
|
|
525
|
+
node_type = token.type
|
|
526
|
+
tag = token.tag
|
|
527
|
+
|
|
528
|
+
entering = token.nesting == 1
|
|
529
|
+
exiting = token.nesting == -1
|
|
530
|
+
self_closing = token.nesting == 0
|
|
531
|
+
|
|
532
|
+
if node_type == "text":
|
|
533
|
+
context.on_text(token.content, node_type)
|
|
534
|
+
elif node_type == "hardbreak":
|
|
535
|
+
context.on_text("\n", node_type)
|
|
536
|
+
elif node_type == "softbreak":
|
|
537
|
+
context.on_text(" ", node_type)
|
|
538
|
+
elif node_type == "link_open":
|
|
539
|
+
href = str(token.attrs.get("href", ""))
|
|
540
|
+
if self.hyperlinks:
|
|
541
|
+
link_style = console.get_style("markdown.link_url", default="none")
|
|
542
|
+
link_style += Style(link=href)
|
|
543
|
+
context.enter_style(link_style)
|
|
544
|
+
else:
|
|
545
|
+
context.stack.push(Link.create(self, token))
|
|
546
|
+
elif node_type == "html_inline":
|
|
547
|
+
if token.content == "<kbd>":
|
|
548
|
+
kbd_style = console.get_style("markdown.kbd", default="bold")
|
|
549
|
+
context.enter_style(kbd_style)
|
|
550
|
+
elif token.content == "</kbd>":
|
|
551
|
+
context.leave_style()
|
|
552
|
+
else:
|
|
553
|
+
continue
|
|
554
|
+
elif node_type == "link_close":
|
|
555
|
+
if self.hyperlinks:
|
|
556
|
+
context.leave_style()
|
|
557
|
+
else:
|
|
558
|
+
element = context.stack.pop()
|
|
559
|
+
assert isinstance(element, Link)
|
|
560
|
+
link_style = console.get_style("markdown.link", default="none")
|
|
561
|
+
context.enter_style(link_style)
|
|
562
|
+
context.on_text(element.text.plain, node_type)
|
|
563
|
+
context.leave_style()
|
|
564
|
+
context.on_text(" (", node_type)
|
|
565
|
+
link_url_style = console.get_style(
|
|
566
|
+
"markdown.link_url", default="none"
|
|
567
|
+
)
|
|
568
|
+
context.enter_style(link_url_style)
|
|
569
|
+
context.on_text(element.href, node_type)
|
|
570
|
+
context.leave_style()
|
|
571
|
+
context.on_text(")", node_type)
|
|
572
|
+
elif (
|
|
573
|
+
tag in inline_style_tags
|
|
574
|
+
and node_type != "fence"
|
|
575
|
+
and node_type != "code_block"
|
|
576
|
+
):
|
|
577
|
+
if entering:
|
|
578
|
+
context.enter_style(f"markdown.{tag}")
|
|
579
|
+
elif exiting:
|
|
580
|
+
context.leave_style()
|
|
581
|
+
else:
|
|
582
|
+
context.enter_style(f"markdown.{tag}")
|
|
583
|
+
if token.content:
|
|
584
|
+
context.on_text(token.content, node_type)
|
|
585
|
+
context.leave_style()
|
|
586
|
+
else:
|
|
587
|
+
element_class = self.elements.get(token.type) or UnknownElement
|
|
588
|
+
element = element_class.create(self, token)
|
|
589
|
+
|
|
590
|
+
if entering or self_closing:
|
|
591
|
+
context.stack.push(element)
|
|
592
|
+
element.on_enter(context)
|
|
593
|
+
|
|
594
|
+
if exiting:
|
|
595
|
+
element = context.stack.pop()
|
|
596
|
+
should_render = not context.stack or (
|
|
597
|
+
context.stack
|
|
598
|
+
and context.stack.top.on_child_close(context, element)
|
|
599
|
+
)
|
|
600
|
+
if should_render:
|
|
601
|
+
if not context.stack:
|
|
602
|
+
yield from self._render_top_element(
|
|
603
|
+
console, options, element, new_line, _new_line_segment
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
if new_line:
|
|
607
|
+
yield _new_line_segment
|
|
608
|
+
yield from console.render(element, context.options)
|
|
609
|
+
elif self_closing:
|
|
610
|
+
context.stack.pop()
|
|
611
|
+
text = token.content
|
|
612
|
+
if text is not None:
|
|
613
|
+
element.on_text(context, text)
|
|
614
|
+
|
|
615
|
+
should_render = (
|
|
616
|
+
not context.stack
|
|
617
|
+
or context.stack
|
|
618
|
+
and context.stack.top.on_child_close(context, element)
|
|
619
|
+
)
|
|
620
|
+
if should_render:
|
|
621
|
+
if not context.stack:
|
|
622
|
+
yield from self._render_top_element(
|
|
623
|
+
console, options, element, new_line and node_type != "inline", _new_line_segment
|
|
624
|
+
)
|
|
625
|
+
else:
|
|
626
|
+
if new_line and node_type != "inline":
|
|
627
|
+
yield _new_line_segment
|
|
628
|
+
yield from console.render(element, context.options)
|
|
629
|
+
|
|
630
|
+
if exiting or self_closing:
|
|
631
|
+
element.on_leave(context)
|
|
632
|
+
new_line = element.new_line
|
mdok/theme.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Dict
|
|
3
|
+
from rich import box
|
|
4
|
+
from rich.box import Box
|
|
5
|
+
from rich.theme import Theme
|
|
6
|
+
|
|
7
|
+
# Clean Booktabs Box: top rule, header separator, bottom rule, no vertical bars.
|
|
8
|
+
BOOKTABS = box.HORIZONTALS
|
|
9
|
+
|
|
10
|
+
BOX_STYLES: Dict[str, Box] = {
|
|
11
|
+
"booktabs": box.HORIZONTALS,
|
|
12
|
+
"simple": box.SIMPLE,
|
|
13
|
+
"simple_head": box.SIMPLE_HEAD,
|
|
14
|
+
"minimal": box.MINIMAL,
|
|
15
|
+
"rounded": box.ROUNDED,
|
|
16
|
+
"none": box.ASCII,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Subtle, elegant document theme (not neon/flashy)
|
|
20
|
+
DEFAULT_THEME = Theme({
|
|
21
|
+
"markdown.h1": "bold bright_white underline",
|
|
22
|
+
"markdown.h1.border": "dim cyan",
|
|
23
|
+
"markdown.h2": "bold cyan",
|
|
24
|
+
"markdown.h3": "bold white",
|
|
25
|
+
"markdown.h4": "bold dim white",
|
|
26
|
+
"markdown.h5": "italic dim white",
|
|
27
|
+
"markdown.h6": "dim white",
|
|
28
|
+
"markdown.hr": "dim bright_black",
|
|
29
|
+
"markdown.item.bullet": "cyan",
|
|
30
|
+
"markdown.item.number": "dim cyan",
|
|
31
|
+
"markdown.block_quote": "dim italic white",
|
|
32
|
+
"markdown.block_quote.border": "dim cyan",
|
|
33
|
+
"markdown.link": "underline cyan",
|
|
34
|
+
"markdown.link_url": "dim underline bright_blue",
|
|
35
|
+
"markdown.table.border": "dim bright_black",
|
|
36
|
+
"markdown.table.header": "bold white",
|
|
37
|
+
"markdown.code": "#e0af68 on grey15",
|
|
38
|
+
"markdown.code_block": "none",
|
|
39
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdok
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A document-grade terminal markdown reader with responsive centering and clean tables
|
|
5
|
+
Author: Anton
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anton/mdok
|
|
8
|
+
Project-URL: Repository, https://github.com/anton/mdok
|
|
9
|
+
Project-URL: Issues, https://github.com/anton/mdok/issues
|
|
10
|
+
Keywords: markdown,terminal,cli,reader,rich,viewer,booktabs
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Requires-Dist: markdown-it-py>=3.0.0
|
|
30
|
+
Requires-Dist: pygments>=2.15.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# mdok
|
|
36
|
+
|
|
37
|
+
A small CLI tool for reading Markdown files in the terminal.
|
|
38
|
+
|
|
39
|
+
Most terminal renderers either stretch text across the entire display width or force it into a rigid, narrow column on the left edge. Tables often fuse rows together, eat technical tags like `<Type>`, or ignore `<br>` line breaks.
|
|
40
|
+
|
|
41
|
+
`mdok` formats Markdown like a clean document:
|
|
42
|
+
|
|
43
|
+
- **Proportional width & centering**: Centers text on wide displays with gentle margins and scales comfortably (~75% of terminal width up to ~120 columns).
|
|
44
|
+
- **Readable tables**: LaTeX *booktabs* style—horizontal rules, no heavy ASCII borders, soft cell wrapping, alignment support, and row dividers so multiline rows don't run together.
|
|
45
|
+
- **Tag & line break handling**: Preserves technical tags (like `<Tag>` or `<Property>`) and respects `<br>` breaks inside cells.
|
|
46
|
+
- **Muted colors**: Inline code uses a warm amber (`#e0af68`) on a subtle dark badge instead of bright yellow. Code blocks default to the Nord syntax theme.
|
|
47
|
+
- **Automatic pager**: Pipes into `less` (with colors preserved) when the document is longer than your terminal screen.
|
|
48
|
+
- **GitHub-style callouts**: Renders `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, etc.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install mdok
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or from source:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/anton/mdok.git
|
|
62
|
+
cd mdok
|
|
63
|
+
pip install .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For development:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install -e .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
Read a file:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
mdok README.md
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Pipe from standard input:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cat doc.md | mdok
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Options
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
mdok doc.md -w 100 # Explicit document width in columns
|
|
92
|
+
mdok doc.md -m 4 # Explicit left margin
|
|
93
|
+
mdok doc.md --no-center # Align to left edge
|
|
94
|
+
mdok doc.md -b rounded # Table style (booktabs, rounded, simple, minimal, none)
|
|
95
|
+
mdok doc.md --code-theme gruvbox-dark # Syntax highlighting theme
|
|
96
|
+
mdok doc.md --no-pager # Print directly without pager
|
|
97
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mdok/__init__.py,sha256=3DmJIMFmyvDA3TIO0X8ftOpmsvoCzgxD6RbLSsCNvSo,77
|
|
2
|
+
mdok/__main__.py,sha256=e-fUh0TrFsGxvW6tvN5HHkwH7N7KsJDA1mr8vX3c40M,65
|
|
3
|
+
mdok/canvas.py,sha256=0vxS2EPhTwHT-uvKszY9W67EklvO4kn0Z7wT2KedeHA,3389
|
|
4
|
+
mdok/cli.py,sha256=wBpYWk0Ux8_SnS4RJzDXkkF6R0ZQ7BkJv-w7RUBK_IM,4727
|
|
5
|
+
mdok/renderer.py,sha256=H-h3bgYtQfIPs71x1059JWfa4ghiF_15x_pso_tusSk,24014
|
|
6
|
+
mdok/theme.py,sha256=riezBkRNDKIFuxrWG4GcVEN6vG4hy2t6E-bGbdjFYdY,1262
|
|
7
|
+
mdok-0.1.0.dist-info/licenses/LICENSE,sha256=xSlh7azZEQWT03SoSqJ5yebQGWp-pTjrjjjIkHvLDj0,1062
|
|
8
|
+
mdok-0.1.0.dist-info/METADATA,sha256=VRK-_c9ne9HsxMdKvhqfez3tgijojkoTNfjE7Enh-Ds,3281
|
|
9
|
+
mdok-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
mdok-0.1.0.dist-info/entry_points.txt,sha256=c63inRY8L9d9lkYyYNGOTNBSXQJMua6gNdMxe-BWTfM,59
|
|
11
|
+
mdok-0.1.0.dist-info/top_level.txt,sha256=aIxj9ER-bkM8dgzZbf4zReorcG1dNmcTVn-952oMmQI,5
|
|
12
|
+
mdok-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mdok
|