from __future__
import annotations
import sys
from typing
import ClassVar, Iterable
from markdown_it
import MarkdownIt
from markdown_it.token
import Token
if sys.version_info >= (3, 8):
from typing
import get_args
else:
from typing_extensions
import get_args
# pragma: no cover
from rich.table
import Table
from .
import box
from ._loop
import loop_first
from ._stack
import Stack
from .console
import Console, ConsoleOptions, JustifyMethod, RenderResult
from .containers
import Renderables
from .jupyter
import JupyterMixin
from .panel
import Panel
from .rule
import Rule
from .segment
import Segment
from .style
import Style, StyleStack
from .syntax
import Syntax
from .text
import Text, TextType
class MarkdownElement:
new_line: ClassVar[bool] =
True
@classmethod
def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
"""Factory to create markdown element,
Args:
markdown (Markdown): The parent Markdown object.
token (Token): A node
from markdown-it.
Returns:
MarkdownElement: A new markdown element
"""
return cls()
def on_enter(self, context: MarkdownContext) ->
None:
"""Called when the node is entered.
Args:
context (MarkdownContext): The markdown context.
"""
def on_text(self, context: MarkdownContext, text: TextType) ->
None:
"""Called when text is parsed.
Args:
context (MarkdownContext): The markdown context.
"""
def on_leave(self, context: MarkdownContext) ->
None:
"""Called when the parser leaves the element.
Args:
context (MarkdownContext): [description]
"""
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
"""Called when a child element is closed.
This method allows a parent element to take over rendering of its children.
Args:
context (MarkdownContext): The markdown context.
child (MarkdownElement): The child markdown element.
Returns:
bool:
Return True to render the element,
or False to
not render the element.
"""
return True
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
return ()
class UnknownElement(MarkdownElement):
"""An unknown element.
Hopefully there will be no unknown elements,
and we will have a MarkdownElement
for
everything
in the document.
"""
class TextElement(MarkdownElement):
"""Base class for elements that render text."""
style_name =
"none"
def on_enter(self, context: MarkdownContext) ->
None:
self.style = context.enter_style(self.style_name)
self.text = Text(justify=
"left")
def on_text(self, context: MarkdownContext, text: TextType) ->
None:
self.text.append(text, context.current_style
if isinstance(text, str)
else None)
def on_leave(self, context: MarkdownContext) ->
None:
context.leave_style()
class Paragraph(TextElement):
"""A Paragraph."""
style_name =
"markdown.paragraph"
justify: JustifyMethod
@classmethod
def create(cls, markdown: Markdown, token: Token) -> Paragraph:
return cls(justify=markdown.justify
or "left")
def __init__(self, justify: JustifyMethod) ->
None:
self.justify = justify
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
self.text.justify = self.justify
yield self.text
class Heading(TextElement):
"""A heading."""
@classmethod
def create(cls, markdown: Markdown, token: Token) -> Heading:
return cls(token.tag)
def on_enter(self, context: MarkdownContext) ->
None:
self.text = Text()
context.enter_style(self.style_name)
def __init__(self, tag: str) ->
None:
self.tag = tag
self.style_name = f
"markdown.{tag}"
super().__init__()
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
text = self.text
text.justify =
"center"
if self.tag ==
"h1":
# Draw a border around h1s
yield Panel(
text,
box=box.HEAVY,
style=
"markdown.h1.border",
)
else:
# Styled text for h2 and beyond
if self.tag ==
"h2":
yield Text(
"")
yield text
class CodeBlock(TextElement):
"""A code block with syntax highlighting."""
style_name =
"markdown.code_block"
@classmethod
def create(cls, markdown: Markdown, token: Token) -> CodeBlock:
node_info = token.info
or ""
lexer_name = node_info.partition(
" ")[0]
return cls(lexer_name
or "text", markdown.code_theme)
def __init__(self, lexer_name: str, theme: str) ->
None:
self.lexer_name = lexer_name
self.theme = theme
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
code = str(self.text).rstrip()
syntax = Syntax(
code, self.lexer_name, theme=self.theme, word_wrap=
True, padding=1
)
yield syntax
class BlockQuote(TextElement):
"""A block quote."""
style_name =
"markdown.block_quote"
def __init__(self) ->
None:
self.elements: Renderables = Renderables()
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
self.elements.append(child)
return False
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
render_options = options.update(width=options.max_width - 4)
lines = console.render_lines(self.elements, render_options, style=self.style)
style = self.style
new_line = Segment(
"\n")
padding = Segment(
"▌ ", style)
for line
in lines:
yield padding
yield from line
yield new_line
class HorizontalRule(MarkdownElement):
"""A horizontal rule to divide sections."""
new_line =
False
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
style = console.get_style(
"markdown.hr", default=
"none")
yield Rule(style=style)
class TableElement(MarkdownElement):
"""MarkdownElement corresponding to `table_open`."""
def __init__(self) ->
None:
self.header: TableHeaderElement |
None =
None
self.body: TableBodyElement |
None =
None
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
if isinstance(child, TableHeaderElement):
self.header = child
elif isinstance(child, TableBodyElement):
self.body = child
else:
raise RuntimeError(
"Couldn't process markdown table.")
return False
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
table = Table(box=box.SIMPLE_HEAVY)
if self.header
is not None and self.header.row
is not None:
for column
in self.header.row.cells:
table.add_column(column.content)
if self.body
is not None:
for row
in self.body.rows:
row_content = [element.content
for element
in row.cells]
table.add_row(*row_content)
yield table
class TableHeaderElement(MarkdownElement):
"""MarkdownElement corresponding to `thead_open` and `thead_close`."""
def __init__(self) ->
None:
self.row: TableRowElement |
None =
None
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
assert isinstance(child, TableRowElement)
self.row = child
return False
class TableBodyElement(MarkdownElement):
"""MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
def __init__(self) ->
None:
self.rows: list[TableRowElement] = []
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
assert isinstance(child, TableRowElement)
self.rows.append(child)
return False
class TableRowElement(MarkdownElement):
"""MarkdownElement corresponding to `tr_open` and `tr_close`."""
def __init__(self) ->
None:
self.cells: list[TableDataElement] = []
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
assert isinstance(child, TableDataElement)
self.cells.append(child)
return False
class TableDataElement(MarkdownElement):
"""MarkdownElement corresponding to `td_open` and `td_close`
and `th_open`
and `th_close`.
"""
@classmethod
def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
style = str(token.attrs.get(
"style"))
or ""
justify: JustifyMethod
if "text-align:right" in style:
justify =
"right"
elif "text-align:center" in style:
justify =
"center"
elif "text-align:left" in style:
justify =
"left"
else:
justify =
"default"
assert justify
in get_args(JustifyMethod)
return cls(justify=justify)
def __init__(self, justify: JustifyMethod) ->
None:
self.content: Text = Text(
"", justify=justify)
self.justify = justify
def on_text(self, context: MarkdownContext, text: TextType) ->
None:
text = Text(text)
if isinstance(text, str)
else text
text.stylize(context.current_style)
self.content.append_text(text)
class ListElement(MarkdownElement):
"""A list element."""
@classmethod
def create(cls, markdown: Markdown, token: Token) -> ListElement:
return cls(token.type, int(token.attrs.get(
"start", 1)))
def __init__(self, list_type: str, list_start: int |
None) ->
None:
self.items: list[ListItem] = []
self.list_type = list_type
self.list_start = list_start
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
assert isinstance(child, ListItem)
self.items.append(child)
return False
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self.list_type ==
"bullet_list_open":
for item
in self.items:
yield from item.render_bullet(console, options)
else:
number = 1
if self.list_start
is None else self.list_start
last_number = number + len(self.items)
for index, item
in enumerate(self.items):
yield from item.render_number(
console, options, number + index, last_number
)
class ListItem(TextElement):
"""An item in a list."""
style_name =
"markdown.item"
def __init__(self) ->
None:
self.elements: Renderables = Renderables()
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
self.elements.append(child)
return False
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
render_options = options.update(width=options.max_width - 3)
lines = console.render_lines(self.elements, render_options, style=self.style)
bullet_style = console.get_style(
"markdown.item.bullet", default=
"none")
bullet = Segment(
" • ", bullet_style)
padding = Segment(
" " * 3, bullet_style)
new_line = Segment(
"\n")
for first, line
in loop_first(lines):
yield bullet
if first
else padding
yield from line
yield new_line
def render_number(
self, console: Console, options: ConsoleOptions, number: int, last_number: int
) -> RenderResult:
number_width = len(str(last_number)) + 2
render_options = options.update(width=options.max_width - number_width)
lines = console.render_lines(self.elements, render_options, style=self.style)
number_style = console.get_style(
"markdown.item.number", default=
"none")
new_line = Segment(
"\n")
padding = Segment(
" " * number_width, number_style)
numeral = Segment(f
"{number}".rjust(number_width - 1) +
" ", number_style)
for first, line
in loop_first(lines):
yield numeral
if first
else padding
yield from line
yield new_line
class Link(TextElement):
@classmethod
def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
url = token.attrs.get(
"href",
"#")
return cls(token.content, str(url))
def __init__(self, text: str, href: str):
self.text = Text(text)
self.href = href
class ImageItem(TextElement):
"""Renders a placeholder for an image."""
new_line =
False
@classmethod
def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
"""Factory to create markdown element,
Args:
markdown (Markdown): The parent Markdown object.
token (Any): A token
from markdown-it.
Returns:
MarkdownElement: A new markdown element
"""
return cls(str(token.attrs.get(
"src",
"")), markdown.hyperlinks)
def __init__(self, destination: str, hyperlinks: bool) ->
None:
self.destination = destination
self.hyperlinks = hyperlinks
self.link: str |
None =
None
super().__init__()
def on_enter(self, context: MarkdownContext) ->
None:
self.link = context.current_style.link
self.text = Text(justify=
"left")
super().on_enter(context)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
link_style = Style(link=self.link
or self.destination
or None)
title = self.text
or Text(self.destination.strip(
"/").rsplit(
"/", 1)[-1])
if self.hyperlinks:
title.stylize(link_style)
text = Text.assemble(
"🌆 ", title,
" ", end=
"")
yield text
class MarkdownContext:
"""Manages the console render state."""
def __init__(
self,
console: Console,
options: ConsoleOptions,
style: Style,
inline_code_lexer: str |
None =
None,
inline_code_theme: str =
"monokai",
) ->
None:
self.console = console
self.options = options
self.style_stack: StyleStack = StyleStack(style)
self.stack: Stack[MarkdownElement] = Stack()
self._syntax: Syntax |
None =
None
if inline_code_lexer
is not None:
self._syntax = Syntax(
"", inline_code_lexer, theme=inline_code_theme)
@property
def current_style(self) -> Style:
"""Current style which is the product of all styles on the stack."""
return self.style_stack.current
def on_text(self, text: str, node_type: str) ->
None:
"""Called when the parser visits text."""
if node_type
in {
"fence",
"code_inline"}
and self._syntax
is not None:
highlight_text = self._syntax.highlight(text)
highlight_text.rstrip()
self.stack.top.on_text(
self, Text.assemble(highlight_text, style=self.style_stack.current)
)
else:
self.stack.top.on_text(self, text)
def enter_style(self, style_name: str | Style) -> Style:
"""Enter a style context."""
style = self.console.get_style(style_name, default=
"none")
self.style_stack.push(style)
return self.current_style
def leave_style(self) -> Style:
"""Leave a style context."""
style = self.style_stack.pop()
return style
class Markdown(JupyterMixin):
"""A Markdown renderable.
Args:
markup (str): A string containing markdown.
code_theme (str, optional): Pygments theme
for code blocks. Defaults to
"monokai". See
https://pygments.org/styles/ for code themes.
justify (JustifyMethod, optional): Justify value
for paragraphs. Defaults to
None.
style (Union[str, Style], optional): Optional style to apply to markdown.
hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``
True``.
inline_code_lexer: (str, optional): Lexer to use
if inline code highlighting
is
enabled. Defaults to
None.
inline_code_theme: (Optional[str], optional): Pygments theme
for inline code
highlighting,
or None for no highlighting. Defaults to
None.
"""
elements: ClassVar[dict[str, type[MarkdownElement]]] = {
"paragraph_open": Paragraph,
"heading_open": Heading,
"fence": CodeBlock,
"code_block": CodeBlock,
"blockquote_open": BlockQuote,
"hr": HorizontalRule,
"bullet_list_open": ListElement,
"ordered_list_open": ListElement,
"list_item_open": ListItem,
"image": ImageItem,
"table_open": TableElement,
"tbody_open": TableBodyElement,
"thead_open": TableHeaderElement,
"tr_open": TableRowElement,
"td_open": TableDataElement,
"th_open": TableDataElement,
}
inlines = {
"em",
"strong",
"code",
"s"}
def __init__(
self,
markup: str,
code_theme: str =
"monokai",
justify: JustifyMethod |
None =
None,
style: str | Style =
"none",
hyperlinks: bool =
True,
inline_code_lexer: str |
None =
None,
inline_code_theme: str |
None =
None,
) ->
None:
parser = MarkdownIt().enable(
"strikethrough").enable(
"table")
self.markup = markup
self.parsed = parser.parse(markup)
self.code_theme = code_theme
self.justify: JustifyMethod |
None = justify
self.style = style
self.hyperlinks = hyperlinks
self.inline_code_lexer = inline_code_lexer
self.inline_code_theme = inline_code_theme
or code_theme
def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
"""Flattens the token stream."""
for token
in tokens:
is_fence = token.type ==
"fence"
is_image = token.tag ==
"img"
if token.children
and not (is_image
or is_fence):
yield from self._flatten_tokens(token.children)
else:
yield token
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
"""Render markdown to the console."""
style = console.get_style(self.style, default=
"none")
options = options.update(height=
None)
context = MarkdownContext(
console,
options,
style,
inline_code_lexer=self.inline_code_lexer,
inline_code_theme=self.inline_code_theme,
)
tokens = self.parsed
inline_style_tags = self.inlines
new_line =
False
_new_line_segment = Segment.line()
for token
in self._flatten_tokens(tokens):
node_type = token.type
tag = token.tag
entering = token.nesting == 1
exiting = token.nesting == -1
self_closing = token.nesting == 0
if node_type ==
"text":
context.on_text(token.content, node_type)
elif node_type ==
"hardbreak":
context.on_text(
"\n", node_type)
elif node_type ==
"softbreak":
context.on_text(
" ", node_type)
elif node_type ==
"link_open":
href = str(token.attrs.get(
"href",
""))
if self.hyperlinks:
link_style = console.get_style(
"markdown.link_url", default=
"none")
link_style += Style(link=href)
context.enter_style(link_style)
else:
context.stack.push(Link.create(self, token))
elif node_type ==
"link_close":
if self.hyperlinks:
context.leave_style()
else:
element = context.stack.pop()
assert isinstance(element, Link)
link_style = console.get_style(
"markdown.link", default=
"none")
context.enter_style(link_style)
context.on_text(element.text.plain, node_type)
context.leave_style()
context.on_text(
" (", node_type)
link_url_style = console.get_style(
"markdown.link_url", default=
"none"
)
context.enter_style(link_url_style)
context.on_text(element.href, node_type)
context.leave_style()
context.on_text(
")", node_type)
elif (
tag
in inline_style_tags
and node_type !=
"fence"
and node_type !=
"code_block"
):
if entering:
# If it's an opening inline token e.g. strong, em, etc.
# Then we move into a style context i.e. push to stack.
context.enter_style(f
"markdown.{tag}")
elif exiting:
# If it's a closing inline style, then we pop the style
# off of the stack, to move out of the context of it...
context.leave_style()
else:
# If it's a self-closing inline style e.g. `code_inline`
context.enter_style(f
"markdown.{tag}")
if token.content:
context.on_text(token.content, node_type)
context.leave_style()
else:
# Map the markdown tag -> MarkdownElement renderable
element_class = self.elements.get(token.type)
or UnknownElement
element = element_class.create(self, token)
if entering
or self_closing:
context.stack.push(element)
element.on_enter(context)
if exiting:
# CLOSING tag
element = context.stack.pop()
should_render =
not context.stack
or (
context.stack
and context.stack.top.on_child_close(context, element)
)
if should_render:
if new_line:
yield _new_line_segment
yield from console.render(element, context.options)
elif self_closing:
# SELF-CLOSING tags (e.g. text, code, image)
context.stack.pop()
text = token.content
if text
is not None:
element.on_text(context, text)
should_render = (
not context.stack
or context.stack
and context.stack.top.on_child_close(context, element)
)
if should_render:
if new_line
and node_type !=
"inline":
yield _new_line_segment
yield from console.render(element, context.options)
if exiting
or self_closing:
element.on_leave(context)
new_line = element.new_line
if __name__ ==
"__main__":
# pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(
description=
"Render Markdown to the console with Rich"
)
parser.add_argument(
"path",
metavar=
"PATH",
help=
"path to markdown file, or - for stdin",
)
parser.add_argument(
"-c",
"--force-color",
dest=
"force_color",
action=
"store_true",
default=
None,
help=
"force color for non-terminals",
)
parser.add_argument(
"-t",
"--code-theme",
dest=
"code_theme",
default=
"monokai",
help=
"pygments code theme",
)
parser.add_argument(
"-i",
"--inline-code-lexer",
dest=
"inline_code_lexer",
default=
None,
help=
"inline_code_lexer",
)
parser.add_argument(
"-y",
"--hyperlinks",
dest=
"hyperlinks",
action=
"store_true",
help=
"enable hyperlinks",
)
parser.add_argument(
"-w",
"--width",
type=int,
dest=
"width",
default=
None,
help=
"width of output (default will auto-detect)",
)
parser.add_argument(
"-j",
"--justify",
dest=
"justify",
action=
"store_true",
help=
"enable full text justify",
)
parser.add_argument(
"-p",
"--page",
dest=
"page",
action=
"store_true",
help=
"use pager to scroll output",
)
args = parser.parse_args()
from rich.console
import Console
if args.path ==
"-":
markdown_body = sys.stdin.read()
else:
with open(args.path, encoding=
"utf-8")
as markdown_file:
markdown_body = markdown_file.read()
markdown = Markdown(
markdown_body,
justify=
"full" if args.justify
else "left",
code_theme=args.code_theme,
hyperlinks=args.hyperlinks,
inline_code_lexer=args.inline_code_lexer,
)
if args.page:
import io
import pydoc
fileio = io.StringIO()
console = Console(
file=fileio, force_terminal=args.force_color, width=args.width
)
console.print(markdown)
pydoc.pager(fileio.getvalue())
else:
console = Console(
force_terminal=args.force_color, width=args.width, record=
True
)
console.print(markdown)