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 ADDED
@@ -0,0 +1,4 @@
1
+ """mdok - Document-grade Markdown Reader for CLI."""
2
+
3
+ __version__ = "0.1.0"
4
+
mdok/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mdok.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mda = mdok.cli:main
3
+ mdok = mdok.cli:main
@@ -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