codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import sys4from dataclasses import dataclass5from typing import ClassVar, Iterable, get_args6 7from markdown_it import MarkdownIt8from markdown_it.token import Token9 10from rich.table import Table11 12from . import box13from ._loop import loop_first14from ._stack import Stack15from .console import Console, ConsoleOptions, JustifyMethod, RenderResult16from .containers import Renderables17from .jupyter import JupyterMixin18from .rule import Rule19from .segment import Segment20from .style import Style, StyleStack21from .syntax import Syntax22from .text import Text, TextType23 24 25class MarkdownElement:26 new_line: ClassVar[bool] = True27 28 @classmethod29 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:30 """Factory to create markdown element,31 32 Args:33 markdown (Markdown): The parent Markdown object.34 token (Token): A node from markdown-it.35 36 Returns:37 MarkdownElement: A new markdown element38 """39 return cls()40 41 def on_enter(self, context: MarkdownContext) -> None:42 """Called when the node is entered.43 44 Args:45 context (MarkdownContext): The markdown context.46 """47 48 def on_text(self, context: MarkdownContext, text: TextType) -> None:49 """Called when text is parsed.50 51 Args:52 context (MarkdownContext): The markdown context.53 """54 55 def on_leave(self, context: MarkdownContext) -> None:56 """Called when the parser leaves the element.57 58 Args:59 context (MarkdownContext): [description]60 """61 62 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:63 """Called when a child element is closed.64 65 This method allows a parent element to take over rendering of its children.66 67 Args:68 context (MarkdownContext): The markdown context.69 child (MarkdownElement): The child markdown element.70 71 Returns:72 bool: Return True to render the element, or False to not render the element.73 """74 return True75 76 def __rich_console__(77 self, console: Console, options: ConsoleOptions78 ) -> RenderResult:79 return ()80 81 82class UnknownElement(MarkdownElement):83 """An unknown element.84 85 Hopefully there will be no unknown elements, and we will have a MarkdownElement for86 everything in the document.87 88 """89 90 91class TextElement(MarkdownElement):92 """Base class for elements that render text."""93 94 style_name = "none"95 96 def on_enter(self, context: MarkdownContext) -> None:97 self.style = context.enter_style(self.style_name)98 self.text = Text(justify="left")99 100 def on_text(self, context: MarkdownContext, text: TextType) -> None:101 self.text.append(text, context.current_style if isinstance(text, str) else None)102 103 def on_leave(self, context: MarkdownContext) -> None:104 context.leave_style()105 106 107class Paragraph(TextElement):108 """A Paragraph."""109 110 style_name = "markdown.paragraph"111 justify: JustifyMethod112 113 @classmethod114 def create(cls, markdown: Markdown, token: Token) -> Paragraph:115 return cls(justify=markdown.justify or "left")116 117 def __init__(self, justify: JustifyMethod) -> None:118 self.justify = justify119 120 def __rich_console__(121 self, console: Console, options: ConsoleOptions122 ) -> RenderResult:123 self.text.justify = self.justify124 yield self.text125 126 127@dataclass128class HeadingFormat:129 justify: JustifyMethod = "left"130 style: str = ""131 132 133class Heading(TextElement):134 """A heading."""135 136 LEVEL_ALIGN: ClassVar[dict[str, JustifyMethod]] = {137 "h1": "center",138 "h2": "left",139 "h3": "left",140 "h4": "left",141 "h5": "left",142 "h6": "left",143 }144 145 @classmethod146 def create(cls, markdown: Markdown, token: Token) -> Heading:147 return cls(token.tag)148 149 def on_enter(self, context: MarkdownContext) -> None:150 self.text = Text()151 context.enter_style(self.style_name)152 153 def __init__(self, tag: str) -> None:154 self.tag = tag155 self.style_name = f"markdown.{tag}"156 super().__init__()157 158 def __rich_console__(159 self, console: Console, options: ConsoleOptions160 ) -> RenderResult:161 text = self.text.copy()162 heading_justify = self.LEVEL_ALIGN.get(self.tag, "left")163 text.justify = heading_justify164 yield text165 166 167class CodeBlock(TextElement):168 """A code block with syntax highlighting."""169 170 style_name = "markdown.code_block"171 172 @classmethod173 def create(cls, markdown: Markdown, token: Token) -> CodeBlock:174 node_info = token.info or ""175 lexer_name = node_info.partition(" ")[0]176 return cls(lexer_name or "text", markdown.code_theme)177 178 def __init__(self, lexer_name: str, theme: str) -> None:179 self.lexer_name = lexer_name180 self.theme = theme181 182 def __rich_console__(183 self, console: Console, options: ConsoleOptions184 ) -> RenderResult:185 code = str(self.text).rstrip()186 syntax = Syntax(187 code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1188 )189 yield syntax190 191 192class BlockQuote(TextElement):193 """A block quote."""194 195 style_name = "markdown.block_quote"196 197 def __init__(self) -> None:198 self.elements: Renderables = Renderables()199 200 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:201 self.elements.append(child)202 return False203 204 def __rich_console__(205 self, console: Console, options: ConsoleOptions206 ) -> RenderResult:207 render_options = options.update(width=options.max_width - 4)208 lines = console.render_lines(self.elements, render_options, style=self.style)209 style = self.style210 new_line = Segment("\n")211 padding = Segment("▌ ", style)212 for line in lines:213 yield padding214 yield from line215 yield new_line216 217 218class HorizontalRule(MarkdownElement):219 """A horizontal rule to divide sections."""220 221 new_line = False222 223 def __rich_console__(224 self, console: Console, options: ConsoleOptions225 ) -> RenderResult:226 style = console.get_style("markdown.hr", default="none")227 yield Rule(style=style, characters="-")228 yield Text()229 230 231class TableElement(MarkdownElement):232 """MarkdownElement corresponding to `table_open`."""233 234 def __init__(self) -> None:235 self.header: TableHeaderElement | None = None236 self.body: TableBodyElement | None = None237 238 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:239 if isinstance(child, TableHeaderElement):240 self.header = child241 elif isinstance(child, TableBodyElement):242 self.body = child243 else:244 raise RuntimeError("Couldn't process markdown table.")245 return False246 247 def __rich_console__(248 self, console: Console, options: ConsoleOptions249 ) -> RenderResult:250 table = Table(251 box=box.SIMPLE,252 pad_edge=False,253 style="markdown.table.border",254 show_edge=True,255 collapse_padding=True,256 )257 258 if self.header is not None and self.header.row is not None:259 for column in self.header.row.cells:260 heading = column.content.copy()261 heading.stylize("markdown.table.header")262 table.add_column(heading)263 264 if self.body is not None:265 for row in self.body.rows:266 row_content = [element.content for element in row.cells]267 table.add_row(*row_content)268 269 yield table270 271 272class TableHeaderElement(MarkdownElement):273 """MarkdownElement corresponding to `thead_open` and `thead_close`."""274 275 def __init__(self) -> None:276 self.row: TableRowElement | None = None277 278 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:279 assert isinstance(child, TableRowElement)280 self.row = child281 return False282 283 284class TableBodyElement(MarkdownElement):285 """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""286 287 def __init__(self) -> None:288 self.rows: list[TableRowElement] = []289 290 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:291 assert isinstance(child, TableRowElement)292 self.rows.append(child)293 return False294 295 296class TableRowElement(MarkdownElement):297 """MarkdownElement corresponding to `tr_open` and `tr_close`."""298 299 def __init__(self) -> None:300 self.cells: list[TableDataElement] = []301 302 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:303 assert isinstance(child, TableDataElement)304 self.cells.append(child)305 return False306 307 308class TableDataElement(MarkdownElement):309 """MarkdownElement corresponding to `td_open` and `td_close`310 and `th_open` and `th_close`."""311 312 @classmethod313 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:314 style = str(token.attrs.get("style")) or ""315 316 justify: JustifyMethod317 if "text-align:right" in style:318 justify = "right"319 elif "text-align:center" in style:320 justify = "center"321 elif "text-align:left" in style:322 justify = "left"323 else:324 justify = "default"325 326 assert justify in get_args(JustifyMethod)327 return cls(justify=justify)328 329 def __init__(self, justify: JustifyMethod) -> None:330 self.content: Text = Text("", justify=justify)331 self.justify = justify332 333 def on_text(self, context: MarkdownContext, text: TextType) -> None:334 if isinstance(text, str):335 self.content.append(text, context.current_style)336 else:337 self.content.append_text(text)338 339 340class ListElement(MarkdownElement):341 """A list element."""342 343 @classmethod344 def create(cls, markdown: Markdown, token: Token) -> ListElement:345 return cls(token.type, int(token.attrs.get("start", 1)))346 347 def __init__(self, list_type: str, list_start: int | None) -> None:348 self.items: list[ListItem] = []349 self.list_type = list_type350 self.list_start = list_start351 352 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:353 assert isinstance(child, ListItem)354 self.items.append(child)355 return False356 357 def __rich_console__(358 self, console: Console, options: ConsoleOptions359 ) -> RenderResult:360 if self.list_type == "bullet_list_open":361 for item in self.items:362 yield from item.render_bullet(console, options)363 else:364 number = 1 if self.list_start is None else self.list_start365 last_number = number + len(self.items)366 for index, item in enumerate(self.items):367 yield from item.render_number(368 console, options, number + index, last_number369 )370 371 372class ListItem(TextElement):373 """An item in a list."""374 375 style_name = "markdown.item"376 377 def __init__(self) -> None:378 self.elements: Renderables = Renderables()379 380 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:381 self.elements.append(child)382 return False383 384 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:385 render_options = options.update(width=options.max_width - 3)386 lines = console.render_lines(self.elements, render_options, style=self.style)387 bullet_style = console.get_style("markdown.item.bullet", default="none")388 389 bullet = Segment(" • ", bullet_style)390 padding = Segment(" " * 3, bullet_style)391 new_line = Segment("\n")392 for first, line in loop_first(lines):393 yield bullet if first else padding394 yield from line395 yield new_line396 397 def render_number(398 self, console: Console, options: ConsoleOptions, number: int, last_number: int399 ) -> RenderResult:400 number_width = len(str(last_number)) + 2401 render_options = options.update(width=options.max_width - number_width)402 lines = console.render_lines(self.elements, render_options, style=self.style)403 number_style = console.get_style("markdown.item.number", default="none")404 405 new_line = Segment("\n")406 padding = Segment(" " * number_width, number_style)407 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)408 for first, line in loop_first(lines):409 yield numeral if first else padding410 yield from line411 yield new_line412 413 414class Link(TextElement):415 @classmethod416 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:417 url = token.attrs.get("href", "#")418 return cls(token.content, str(url))419 420 def __init__(self, text: str, href: str):421 self.text = Text(text)422 self.href = href423 424 425class ImageItem(TextElement):426 """Renders a placeholder for an image."""427 428 new_line = False429 430 @classmethod431 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:432 """Factory to create markdown element,433 434 Args:435 markdown (Markdown): The parent Markdown object.436 token (Any): A token from markdown-it.437 438 Returns:439 MarkdownElement: A new markdown element440 """441 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)442 443 def __init__(self, destination: str, hyperlinks: bool) -> None:444 self.destination = destination445 self.hyperlinks = hyperlinks446 self.link: str | None = None447 super().__init__()448 449 def on_enter(self, context: MarkdownContext) -> None:450 self.link = context.current_style.link451 self.text = Text(justify="left")452 super().on_enter(context)453 454 def __rich_console__(455 self, console: Console, options: ConsoleOptions456 ) -> RenderResult:457 link_style = Style(link=self.link or self.destination or None)458 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])459 if self.hyperlinks:460 title.stylize(link_style)461 text = Text.assemble("🌆 ", title, " ", end="")462 yield text463 464 465class MarkdownContext:466 """Manages the console render state."""467 468 def __init__(469 self,470 console: Console,471 options: ConsoleOptions,472 style: Style,473 inline_code_lexer: str | None = None,474 inline_code_theme: str = "monokai",475 ) -> None:476 self.console = console477 self.options = options478 self.style_stack: StyleStack = StyleStack(style)479 self.stack: Stack[MarkdownElement] = Stack()480 481 self._syntax: Syntax | None = None482 if inline_code_lexer is not None:483 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)484 485 @property486 def current_style(self) -> Style:487 """Current style which is the product of all styles on the stack."""488 return self.style_stack.current489 490 def on_text(self, text: str, node_type: str) -> None:491 """Called when the parser visits text."""492 if node_type in {"fence", "code_inline"} and self._syntax is not None:493 highlight_text = self._syntax.highlight(text)494 highlight_text.rstrip()495 self.stack.top.on_text(496 self, Text.assemble(highlight_text, style=self.style_stack.current)497 )498 else:499 self.stack.top.on_text(self, text)500 501 def enter_style(self, style_name: str | Style) -> Style:502 """Enter a style context."""503 style = self.console.get_style(style_name, default="none")504 self.style_stack.push(style)505 return self.current_style506 507 def leave_style(self) -> Style:508 """Leave a style context."""509 style = self.style_stack.pop()510 return style511 512 513class Markdown(JupyterMixin):514 """A Markdown renderable.515 516 Args:517 markup (str): A string containing markdown.518 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". See https://pygments.org/styles/ for code themes.519 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.520 style (Union[str, Style], optional): Optional style to apply to markdown.521 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.522 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is523 enabled. Defaults to None.524 inline_code_theme: (Optional[str], optional): Pygments theme for inline code525 highlighting, or None for no highlighting. Defaults to None.526 """527 528 elements: ClassVar[dict[str, type[MarkdownElement]]] = {529 "paragraph_open": Paragraph,530 "heading_open": Heading,531 "fence": CodeBlock,532 "code_block": CodeBlock,533 "blockquote_open": BlockQuote,534 "hr": HorizontalRule,535 "bullet_list_open": ListElement,536 "ordered_list_open": ListElement,537 "list_item_open": ListItem,538 "image": ImageItem,539 "table_open": TableElement,540 "tbody_open": TableBodyElement,541 "thead_open": TableHeaderElement,542 "tr_open": TableRowElement,543 "td_open": TableDataElement,544 "th_open": TableDataElement,545 }546 547 inlines = {"em", "strong", "code", "s"}548 549 def __init__(550 self,551 markup: str,552 code_theme: str = "monokai",553 justify: JustifyMethod | None = None,554 style: str | Style = "none",555 hyperlinks: bool = True,556 inline_code_lexer: str | None = None,557 inline_code_theme: str | None = None,558 ) -> None:559 parser = MarkdownIt().enable("strikethrough").enable("table")560 self.markup = markup561 self.parsed = parser.parse(markup)562 self.code_theme = code_theme563 self.justify: JustifyMethod | None = justify564 self.style = style565 self.hyperlinks = hyperlinks566 self.inline_code_lexer = inline_code_lexer567 self.inline_code_theme = inline_code_theme or code_theme568 569 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:570 """Flattens the token stream."""571 for token in tokens:572 is_fence = token.type == "fence"573 is_image = token.tag == "img"574 if token.children and not (is_image or is_fence):575 yield from self._flatten_tokens(token.children)576 else:577 yield token578 579 def __rich_console__(580 self, console: Console, options: ConsoleOptions581 ) -> RenderResult:582 """Render markdown to the console."""583 style = console.get_style(self.style, default="none")584 options = options.update(height=None)585 context = MarkdownContext(586 console,587 options,588 style,589 inline_code_lexer=self.inline_code_lexer,590 inline_code_theme=self.inline_code_theme,591 )592 tokens = self.parsed593 inline_style_tags = self.inlines594 new_line = False595 _new_line_segment = Segment.line()596 597 for token in self._flatten_tokens(tokens):598 node_type = token.type599 tag = token.tag600 601 entering = token.nesting == 1602 exiting = token.nesting == -1603 self_closing = token.nesting == 0604 605 if node_type == "text":606 context.on_text(token.content, node_type)607 elif node_type == "hardbreak":608 context.on_text("\n", node_type)609 elif node_type == "softbreak":610 context.on_text(" ", node_type)611 elif node_type == "link_open":612 href = str(token.attrs.get("href", ""))613 if self.hyperlinks:614 link_style = console.get_style("markdown.link_url", default="none")615 link_style += Style(link=href)616 context.enter_style(link_style)617 else:618 context.stack.push(Link.create(self, token))619 elif node_type == "html_inline":620 if token.content == "<kbd>":621 kbd_style = console.get_style("markdown.kbd", default="bold")622 context.enter_style(kbd_style)623 elif token.content == "</kbd>":624 context.leave_style()625 else:626 continue627 elif node_type == "link_close":628 if self.hyperlinks:629 context.leave_style()630 else:631 element = context.stack.pop()632 assert isinstance(element, Link)633 link_style = console.get_style("markdown.link", default="none")634 context.enter_style(link_style)635 context.on_text(element.text.plain, node_type)636 context.leave_style()637 context.on_text(" (", node_type)638 link_url_style = console.get_style(639 "markdown.link_url", default="none"640 )641 context.enter_style(link_url_style)642 context.on_text(element.href, node_type)643 context.leave_style()644 context.on_text(")", node_type)645 elif (646 tag in inline_style_tags647 and node_type != "fence"648 and node_type != "code_block"649 ):650 if entering:651 # If it's an opening inline token e.g. strong, em, etc.652 # Then we move into a style context i.e. push to stack.653 context.enter_style(f"markdown.{tag}")654 elif exiting:655 # If it's a closing inline style, then we pop the style656 # off of the stack, to move out of the context of it...657 context.leave_style()658 else:659 # If it's a self-closing inline style e.g. `code_inline`660 context.enter_style(f"markdown.{tag}")661 if token.content:662 context.on_text(token.content, node_type)663 context.leave_style()664 else:665 # Map the markdown tag -> MarkdownElement renderable666 element_class = self.elements.get(token.type) or UnknownElement667 element = element_class.create(self, token)668 669 if entering or self_closing:670 context.stack.push(element)671 element.on_enter(context)672 673 if exiting: # CLOSING tag674 element = context.stack.pop()675 676 should_render = not context.stack or (677 context.stack678 and context.stack.top.on_child_close(context, element)679 )680 681 if should_render:682 if new_line:683 yield _new_line_segment684 685 yield from console.render(element, context.options)686 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)687 context.stack.pop()688 text = token.content689 if text is not None:690 element.on_text(context, text)691 692 should_render = (693 not context.stack694 or context.stack695 and context.stack.top.on_child_close(context, element)696 )697 if should_render:698 if new_line and node_type != "inline":699 yield _new_line_segment700 yield from console.render(element, context.options)701 702 if exiting or self_closing:703 element.on_leave(context)704 new_line = element.new_line705 706 707if __name__ == "__main__": # pragma: no cover708 import argparse709 import sys710 711 parser = argparse.ArgumentParser(712 description="Render Markdown to the console with Rich"713 )714 parser.add_argument(715 "path",716 metavar="PATH",717 help="path to markdown file, or - for stdin",718 )719 parser.add_argument(720 "-c",721 "--force-color",722 dest="force_color",723 action="store_true",724 default=None,725 help="force color for non-terminals",726 )727 parser.add_argument(728 "-t",729 "--code-theme",730 dest="code_theme",731 default="monokai",732 help="pygments code theme",733 )734 parser.add_argument(735 "-i",736 "--inline-code-lexer",737 dest="inline_code_lexer",738 default=None,739 help="inline_code_lexer",740 )741 parser.add_argument(742 "-y",743 "--hyperlinks",744 dest="hyperlinks",745 action="store_true",746 help="enable hyperlinks",747 )748 parser.add_argument(749 "-w",750 "--width",751 type=int,752 dest="width",753 default=None,754 help="width of output (default will auto-detect)",755 )756 parser.add_argument(757 "-j",758 "--justify",759 dest="justify",760 action="store_true",761 help="enable full text justify",762 )763 parser.add_argument(764 "-p",765 "--page",766 dest="page",767 action="store_true",768 help="use pager to scroll output",769 )770 args = parser.parse_args()771 772 from rich.console import Console773 774 if args.path == "-":775 markdown_body = sys.stdin.read()776 else:777 with open(args.path, encoding="utf-8") as markdown_file:778 markdown_body = markdown_file.read()779 780 markdown = Markdown(781 markdown_body,782 justify="full" if args.justify else "left",783 code_theme=args.code_theme,784 hyperlinks=args.hyperlinks,785 inline_code_lexer=args.inline_code_lexer,786 )787 if args.page:788 import io789 import pydoc790 791 fileio = io.StringIO()792 console = Console(793 file=fileio, force_terminal=args.force_color, width=args.width794 )795 console.print(markdown)796 pydoc.pager(fileio.getvalue())797 798 else:799 console = Console(800 force_terminal=args.force_color, width=args.width, record=True801 )802 console.print(markdown)803 