codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import sys4from typing import ClassVar, Dict, Iterable, List, Optional, Type, Union5 6from markdown_it import MarkdownIt7from markdown_it.token import Token8 9if sys.version_info >= (3, 8):10 from typing import get_args11else:12 from typing_extensions import get_args # pragma: no cover13 14from rich.table import Table15 16from . import box17from ._loop import loop_first18from ._stack import Stack19from .console import Console, ConsoleOptions, JustifyMethod, RenderResult20from .containers import Renderables21from .jupyter import JupyterMixin22from .panel import Panel23from .rule import Rule24from .segment import Segment25from .style import Style, StyleStack26from .syntax import Syntax27from .text import Text, TextType28 29 30class MarkdownElement:31 new_line: ClassVar[bool] = True32 33 @classmethod34 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":35 """Factory to create markdown element,36 37 Args:38 markdown (Markdown): The parent Markdown object.39 token (Token): A node from markdown-it.40 41 Returns:42 MarkdownElement: A new markdown element43 """44 return cls()45 46 def on_enter(self, context: "MarkdownContext") -> None:47 """Called when the node is entered.48 49 Args:50 context (MarkdownContext): The markdown context.51 """52 53 def on_text(self, context: "MarkdownContext", text: TextType) -> None:54 """Called when text is parsed.55 56 Args:57 context (MarkdownContext): The markdown context.58 """59 60 def on_leave(self, context: "MarkdownContext") -> None:61 """Called when the parser leaves the element.62 63 Args:64 context (MarkdownContext): [description]65 """66 67 def on_child_close(68 self, context: "MarkdownContext", child: "MarkdownElement"69 ) -> bool:70 """Called when a child element is closed.71 72 This method allows a parent element to take over rendering of its children.73 74 Args:75 context (MarkdownContext): The markdown context.76 child (MarkdownElement): The child markdown element.77 78 Returns:79 bool: Return True to render the element, or False to not render the element.80 """81 return True82 83 def __rich_console__(84 self, console: "Console", options: "ConsoleOptions"85 ) -> "RenderResult":86 return ()87 88 89class UnknownElement(MarkdownElement):90 """An unknown element.91 92 Hopefully there will be no unknown elements, and we will have a MarkdownElement for93 everything in the document.94 95 """96 97 98class TextElement(MarkdownElement):99 """Base class for elements that render text."""100 101 style_name = "none"102 103 def on_enter(self, context: "MarkdownContext") -> None:104 self.style = context.enter_style(self.style_name)105 self.text = Text(justify="left")106 107 def on_text(self, context: "MarkdownContext", text: TextType) -> None:108 self.text.append(text, context.current_style if isinstance(text, str) else None)109 110 def on_leave(self, context: "MarkdownContext") -> None:111 context.leave_style()112 113 114class Paragraph(TextElement):115 """A Paragraph."""116 117 style_name = "markdown.paragraph"118 justify: JustifyMethod119 120 @classmethod121 def create(cls, markdown: "Markdown", token: Token) -> "Paragraph":122 return cls(justify=markdown.justify or "left")123 124 def __init__(self, justify: JustifyMethod) -> None:125 self.justify = justify126 127 def __rich_console__(128 self, console: Console, options: ConsoleOptions129 ) -> RenderResult:130 self.text.justify = self.justify131 yield self.text132 133 134class Heading(TextElement):135 """A heading."""136 137 @classmethod138 def create(cls, markdown: "Markdown", token: Token) -> "Heading":139 return cls(token.tag)140 141 def on_enter(self, context: "MarkdownContext") -> None:142 self.text = Text()143 context.enter_style(self.style_name)144 145 def __init__(self, tag: str) -> None:146 self.tag = tag147 self.style_name = f"markdown.{tag}"148 super().__init__()149 150 def __rich_console__(151 self, console: Console, options: ConsoleOptions152 ) -> RenderResult:153 text = self.text154 text.justify = "center"155 if self.tag == "h1":156 # Draw a border around h1s157 yield Panel(158 text,159 box=box.HEAVY,160 style="markdown.h1.border",161 )162 else:163 # Styled text for h2 and beyond164 if self.tag == "h2":165 yield Text("")166 yield text167 168 169class CodeBlock(TextElement):170 """A code block with syntax highlighting."""171 172 style_name = "markdown.code_block"173 174 @classmethod175 def create(cls, markdown: "Markdown", token: Token) -> "CodeBlock":176 node_info = token.info or ""177 lexer_name = node_info.partition(" ")[0]178 return cls(lexer_name or "text", markdown.code_theme)179 180 def __init__(self, lexer_name: str, theme: str) -> None:181 self.lexer_name = lexer_name182 self.theme = theme183 184 def __rich_console__(185 self, console: Console, options: ConsoleOptions186 ) -> RenderResult:187 code = str(self.text).rstrip()188 syntax = Syntax(189 code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1190 )191 yield syntax192 193 194class BlockQuote(TextElement):195 """A block quote."""196 197 style_name = "markdown.block_quote"198 199 def __init__(self) -> None:200 self.elements: Renderables = Renderables()201 202 def on_child_close(203 self, context: "MarkdownContext", child: "MarkdownElement"204 ) -> bool:205 self.elements.append(child)206 return False207 208 def __rich_console__(209 self, console: Console, options: ConsoleOptions210 ) -> RenderResult:211 render_options = options.update(width=options.max_width - 4)212 lines = console.render_lines(self.elements, render_options, style=self.style)213 style = self.style214 new_line = Segment("\n")215 padding = Segment("▌ ", style)216 for line in lines:217 yield padding218 yield from line219 yield new_line220 221 222class HorizontalRule(MarkdownElement):223 """A horizontal rule to divide sections."""224 225 new_line = False226 227 def __rich_console__(228 self, console: Console, options: ConsoleOptions229 ) -> RenderResult:230 style = console.get_style("markdown.hr", default="none")231 yield Rule(style=style)232 233 234class TableElement(MarkdownElement):235 """MarkdownElement corresponding to `table_open`."""236 237 def __init__(self) -> None:238 self.header: TableHeaderElement | None = None239 self.body: TableBodyElement | None = None240 241 def on_child_close(242 self, context: "MarkdownContext", child: "MarkdownElement"243 ) -> bool:244 if isinstance(child, TableHeaderElement):245 self.header = child246 elif isinstance(child, TableBodyElement):247 self.body = child248 else:249 raise RuntimeError("Couldn't process markdown table.")250 return False251 252 def __rich_console__(253 self, console: Console, options: ConsoleOptions254 ) -> RenderResult:255 table = Table(box=box.SIMPLE_HEAVY)256 257 if self.header is not None and self.header.row is not None:258 for column in self.header.row.cells:259 table.add_column(column.content)260 261 if self.body is not None:262 for row in self.body.rows:263 row_content = [element.content for element in row.cells]264 table.add_row(*row_content)265 266 yield table267 268 269class TableHeaderElement(MarkdownElement):270 """MarkdownElement corresponding to `thead_open` and `thead_close`."""271 272 def __init__(self) -> None:273 self.row: TableRowElement | None = None274 275 def on_child_close(276 self, context: "MarkdownContext", child: "MarkdownElement"277 ) -> bool:278 assert isinstance(child, TableRowElement)279 self.row = child280 return False281 282 283class TableBodyElement(MarkdownElement):284 """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""285 286 def __init__(self) -> None:287 self.rows: list[TableRowElement] = []288 289 def on_child_close(290 self, context: "MarkdownContext", child: "MarkdownElement"291 ) -> bool:292 assert isinstance(child, TableRowElement)293 self.rows.append(child)294 return False295 296 297class TableRowElement(MarkdownElement):298 """MarkdownElement corresponding to `tr_open` and `tr_close`."""299 300 def __init__(self) -> None:301 self.cells: List[TableDataElement] = []302 303 def on_child_close(304 self, context: "MarkdownContext", child: "MarkdownElement"305 ) -> bool:306 assert isinstance(child, TableDataElement)307 self.cells.append(child)308 return False309 310 311class TableDataElement(MarkdownElement):312 """MarkdownElement corresponding to `td_open` and `td_close`313 and `th_open` and `th_close`."""314 315 @classmethod316 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":317 style = str(token.attrs.get("style")) or ""318 319 justify: JustifyMethod320 if "text-align:right" in style:321 justify = "right"322 elif "text-align:center" in style:323 justify = "center"324 elif "text-align:left" in style:325 justify = "left"326 else:327 justify = "default"328 329 assert justify in get_args(JustifyMethod)330 return cls(justify=justify)331 332 def __init__(self, justify: JustifyMethod) -> None:333 self.content: Text = Text("", justify=justify)334 self.justify = justify335 336 def on_text(self, context: "MarkdownContext", text: TextType) -> None:337 text = Text(text) if isinstance(text, str) else text338 text.stylize(context.current_style)339 self.content.append_text(text)340 341 342class ListElement(MarkdownElement):343 """A list element."""344 345 @classmethod346 def create(cls, markdown: "Markdown", token: Token) -> "ListElement":347 return cls(token.type, int(token.attrs.get("start", 1)))348 349 def __init__(self, list_type: str, list_start: int | None) -> None:350 self.items: List[ListItem] = []351 self.list_type = list_type352 self.list_start = list_start353 354 def on_child_close(355 self, context: "MarkdownContext", child: "MarkdownElement"356 ) -> bool:357 assert isinstance(child, ListItem)358 self.items.append(child)359 return False360 361 def __rich_console__(362 self, console: Console, options: ConsoleOptions363 ) -> RenderResult:364 if self.list_type == "bullet_list_open":365 for item in self.items:366 yield from item.render_bullet(console, options)367 else:368 number = 1 if self.list_start is None else self.list_start369 last_number = number + len(self.items)370 for index, item in enumerate(self.items):371 yield from item.render_number(372 console, options, number + index, last_number373 )374 375 376class ListItem(TextElement):377 """An item in a list."""378 379 style_name = "markdown.item"380 381 def __init__(self) -> None:382 self.elements: Renderables = Renderables()383 384 def on_child_close(385 self, context: "MarkdownContext", child: "MarkdownElement"386 ) -> bool:387 self.elements.append(child)388 return False389 390 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:391 render_options = options.update(width=options.max_width - 3)392 lines = console.render_lines(self.elements, render_options, style=self.style)393 bullet_style = console.get_style("markdown.item.bullet", default="none")394 395 bullet = Segment(" • ", bullet_style)396 padding = Segment(" " * 3, bullet_style)397 new_line = Segment("\n")398 for first, line in loop_first(lines):399 yield bullet if first else padding400 yield from line401 yield new_line402 403 def render_number(404 self, console: Console, options: ConsoleOptions, number: int, last_number: int405 ) -> RenderResult:406 number_width = len(str(last_number)) + 2407 render_options = options.update(width=options.max_width - number_width)408 lines = console.render_lines(self.elements, render_options, style=self.style)409 number_style = console.get_style("markdown.item.number", default="none")410 411 new_line = Segment("\n")412 padding = Segment(" " * number_width, number_style)413 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)414 for first, line in loop_first(lines):415 yield numeral if first else padding416 yield from line417 yield new_line418 419 420class Link(TextElement):421 @classmethod422 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":423 url = token.attrs.get("href", "#")424 return cls(token.content, str(url))425 426 def __init__(self, text: str, href: str):427 self.text = Text(text)428 self.href = href429 430 431class ImageItem(TextElement):432 """Renders a placeholder for an image."""433 434 new_line = False435 436 @classmethod437 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":438 """Factory to create markdown element,439 440 Args:441 markdown (Markdown): The parent Markdown object.442 token (Any): A token from markdown-it.443 444 Returns:445 MarkdownElement: A new markdown element446 """447 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)448 449 def __init__(self, destination: str, hyperlinks: bool) -> None:450 self.destination = destination451 self.hyperlinks = hyperlinks452 self.link: Optional[str] = None453 super().__init__()454 455 def on_enter(self, context: "MarkdownContext") -> None:456 self.link = context.current_style.link457 self.text = Text(justify="left")458 super().on_enter(context)459 460 def __rich_console__(461 self, console: Console, options: ConsoleOptions462 ) -> RenderResult:463 link_style = Style(link=self.link or self.destination or None)464 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])465 if self.hyperlinks:466 title.stylize(link_style)467 text = Text.assemble("🌆 ", title, " ", end="")468 yield text469 470 471class MarkdownContext:472 """Manages the console render state."""473 474 def __init__(475 self,476 console: Console,477 options: ConsoleOptions,478 style: Style,479 inline_code_lexer: Optional[str] = None,480 inline_code_theme: str = "monokai",481 ) -> None:482 self.console = console483 self.options = options484 self.style_stack: StyleStack = StyleStack(style)485 self.stack: Stack[MarkdownElement] = Stack()486 487 self._syntax: Optional[Syntax] = None488 if inline_code_lexer is not None:489 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)490 491 @property492 def current_style(self) -> Style:493 """Current style which is the product of all styles on the stack."""494 return self.style_stack.current495 496 def on_text(self, text: str, node_type: str) -> None:497 """Called when the parser visits text."""498 if node_type in {"fence", "code_inline"} and self._syntax is not None:499 highlight_text = self._syntax.highlight(text)500 highlight_text.rstrip()501 self.stack.top.on_text(502 self, Text.assemble(highlight_text, style=self.style_stack.current)503 )504 else:505 self.stack.top.on_text(self, text)506 507 def enter_style(self, style_name: Union[str, Style]) -> Style:508 """Enter a style context."""509 style = self.console.get_style(style_name, default="none")510 self.style_stack.push(style)511 return self.current_style512 513 def leave_style(self) -> Style:514 """Leave a style context."""515 style = self.style_stack.pop()516 return style517 518 519class Markdown(JupyterMixin):520 """A Markdown renderable.521 522 Args:523 markup (str): A string containing markdown.524 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai".525 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.526 style (Union[str, Style], optional): Optional style to apply to markdown.527 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.528 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is529 enabled. Defaults to None.530 inline_code_theme: (Optional[str], optional): Pygments theme for inline code531 highlighting, or None for no highlighting. Defaults to None.532 """533 534 elements: ClassVar[Dict[str, Type[MarkdownElement]]] = {535 "paragraph_open": Paragraph,536 "heading_open": Heading,537 "fence": CodeBlock,538 "code_block": CodeBlock,539 "blockquote_open": BlockQuote,540 "hr": HorizontalRule,541 "bullet_list_open": ListElement,542 "ordered_list_open": ListElement,543 "list_item_open": ListItem,544 "image": ImageItem,545 "table_open": TableElement,546 "tbody_open": TableBodyElement,547 "thead_open": TableHeaderElement,548 "tr_open": TableRowElement,549 "td_open": TableDataElement,550 "th_open": TableDataElement,551 }552 553 inlines = {"em", "strong", "code", "s"}554 555 def __init__(556 self,557 markup: str,558 code_theme: str = "monokai",559 justify: Optional[JustifyMethod] = None,560 style: Union[str, Style] = "none",561 hyperlinks: bool = True,562 inline_code_lexer: Optional[str] = None,563 inline_code_theme: Optional[str] = None,564 ) -> None:565 parser = MarkdownIt().enable("strikethrough").enable("table")566 self.markup = markup567 self.parsed = parser.parse(markup)568 self.code_theme = code_theme569 self.justify: Optional[JustifyMethod] = justify570 self.style = style571 self.hyperlinks = hyperlinks572 self.inline_code_lexer = inline_code_lexer573 self.inline_code_theme = inline_code_theme or code_theme574 575 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:576 """Flattens the token stream."""577 for token in tokens:578 is_fence = token.type == "fence"579 is_image = token.tag == "img"580 if token.children and not (is_image or is_fence):581 yield from self._flatten_tokens(token.children)582 else:583 yield token584 585 def __rich_console__(586 self, console: Console, options: ConsoleOptions587 ) -> RenderResult:588 """Render markdown to the console."""589 style = console.get_style(self.style, default="none")590 options = options.update(height=None)591 context = MarkdownContext(592 console,593 options,594 style,595 inline_code_lexer=self.inline_code_lexer,596 inline_code_theme=self.inline_code_theme,597 )598 tokens = self.parsed599 inline_style_tags = self.inlines600 new_line = False601 _new_line_segment = Segment.line()602 603 for token in self._flatten_tokens(tokens):604 node_type = token.type605 tag = token.tag606 607 entering = token.nesting == 1608 exiting = token.nesting == -1609 self_closing = token.nesting == 0610 611 if node_type == "text":612 context.on_text(token.content, node_type)613 elif node_type == "hardbreak":614 context.on_text("\n", node_type)615 elif node_type == "softbreak":616 context.on_text(" ", node_type)617 elif node_type == "link_open":618 href = str(token.attrs.get("href", ""))619 if self.hyperlinks:620 link_style = console.get_style("markdown.link_url", default="none")621 link_style += Style(link=href)622 context.enter_style(link_style)623 else:624 context.stack.push(Link.create(self, token))625 elif node_type == "link_close":626 if self.hyperlinks:627 context.leave_style()628 else:629 element = context.stack.pop()630 assert isinstance(element, Link)631 link_style = console.get_style("markdown.link", default="none")632 context.enter_style(link_style)633 context.on_text(element.text.plain, node_type)634 context.leave_style()635 context.on_text(" (", node_type)636 link_url_style = console.get_style(637 "markdown.link_url", default="none"638 )639 context.enter_style(link_url_style)640 context.on_text(element.href, node_type)641 context.leave_style()642 context.on_text(")", node_type)643 elif (644 tag in inline_style_tags645 and node_type != "fence"646 and node_type != "code_block"647 ):648 if entering:649 # If it's an opening inline token e.g. strong, em, etc.650 # Then we move into a style context i.e. push to stack.651 context.enter_style(f"markdown.{tag}")652 elif exiting:653 # If it's a closing inline style, then we pop the style654 # off of the stack, to move out of the context of it...655 context.leave_style()656 else:657 # If it's a self-closing inline style e.g. `code_inline`658 context.enter_style(f"markdown.{tag}")659 if token.content:660 context.on_text(token.content, node_type)661 context.leave_style()662 else:663 # Map the markdown tag -> MarkdownElement renderable664 element_class = self.elements.get(token.type) or UnknownElement665 element = element_class.create(self, token)666 667 if entering or self_closing:668 context.stack.push(element)669 element.on_enter(context)670 671 if exiting: # CLOSING tag672 element = context.stack.pop()673 674 should_render = not context.stack or (675 context.stack676 and context.stack.top.on_child_close(context, element)677 )678 679 if should_render:680 if new_line:681 yield _new_line_segment682 683 yield from console.render(element, context.options)684 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)685 context.stack.pop()686 text = token.content687 if text is not None:688 element.on_text(context, text)689 690 should_render = (691 not context.stack692 or context.stack693 and context.stack.top.on_child_close(context, element)694 )695 if should_render:696 if new_line:697 yield _new_line_segment698 yield from console.render(element, context.options)699 700 if exiting or self_closing:701 element.on_leave(context)702 new_line = element.new_line703 704 705if __name__ == "__main__": # pragma: no cover706 import argparse707 import sys708 709 parser = argparse.ArgumentParser(710 description="Render Markdown to the console with Rich"711 )712 parser.add_argument(713 "path",714 metavar="PATH",715 help="path to markdown file, or - for stdin",716 )717 parser.add_argument(718 "-c",719 "--force-color",720 dest="force_color",721 action="store_true",722 default=None,723 help="force color for non-terminals",724 )725 parser.add_argument(726 "-t",727 "--code-theme",728 dest="code_theme",729 default="monokai",730 help="pygments code theme",731 )732 parser.add_argument(733 "-i",734 "--inline-code-lexer",735 dest="inline_code_lexer",736 default=None,737 help="inline_code_lexer",738 )739 parser.add_argument(740 "-y",741 "--hyperlinks",742 dest="hyperlinks",743 action="store_true",744 help="enable hyperlinks",745 )746 parser.add_argument(747 "-w",748 "--width",749 type=int,750 dest="width",751 default=None,752 help="width of output (default will auto-detect)",753 )754 parser.add_argument(755 "-j",756 "--justify",757 dest="justify",758 action="store_true",759 help="enable full text justify",760 )761 parser.add_argument(762 "-p",763 "--page",764 dest="page",765 action="store_true",766 help="use pager to scroll output",767 )768 args = parser.parse_args()769 770 from rich.console import Console771 772 if args.path == "-":773 markdown_body = sys.stdin.read()774 else:775 with open(args.path, "rt", encoding="utf-8") as markdown_file:776 markdown_body = markdown_file.read()777 778 markdown = Markdown(779 markdown_body,780 justify="full" if args.justify else "left",781 code_theme=args.code_theme,782 hyperlinks=args.hyperlinks,783 inline_code_lexer=args.inline_code_lexer,784 )785 if args.page:786 import io787 import pydoc788 789 fileio = io.StringIO()790 console = Console(791 file=fileio, force_terminal=args.force_color, width=args.width792 )793 console.print(markdown)794 pydoc.pager(fileio.getvalue())795 796 else:797 console = Console(798 force_terminal=args.force_color, width=args.width, record=True799 )800 console.print(markdown)801 