by Audrey M. Roy Greenfeld | Fri, Jan 31, 2025
This is a proof-of-concept of moving MonsterUI's HTML class injection from lxml post-processing to a custom Mistletoe renderer. Looks like we can get 3x faster Markdown rendering using this simple MonsterHTMLRenderer vs. parsing the rendered HTML to inject the classes.
from fasthtml.common import * from lxml import html, etree from monsterui.all import * import mistletoe from mistletoe import markdown from mistletoe.html_renderer import block_token, HtmlRenderer import timeit
This is what I currently use on this site to render Markdown cells from my Jupyter notebooks:
def StyledMd(m): return Safe(markdown(m))
x = "## A Test Level 2 Heading"
StyledMd(x)
monsterui.franken
has this function. This seems promising! My goal is to add the uk-h2
class to the above:
def render_md(md_content:str, # Markdown content class_map=None, # Class map class_map_mods=None # Additional class map )->FT: # Rendered markdown "Renders markdown using mistletoe and lxml" if md_content=='': return md_content # Check for required dependencies html_content = mistletoe.markdown(md_content) #, mcp.PygmentsRenderer) return NotStr(apply_classes(html_content, class_map, class_map_mods))
render_md(x)
That looks like too many classes? Aha, I see monsterui.franken
defines:
franken_class_map = { 'h1': 'uk-h1 text-4xl font-bold mt-12 mb-6', 'h2': 'uk-h2 text-3xl font-bold mt-10 mb-5', 'h3': 'uk-h3 text-2xl font-semibold mt-8 mb-4', 'h4': 'uk-h4 text-xl font-semibold mt-6 mb-3', # Body text and links 'p': 'text-lg leading-relaxed mb-6', 'a': 'uk-link text-primary hover:text-primary-focus underline', # Lists with proper spacing 'ul': 'uk-list uk-list-disc space-y-2 mb-6 ml-6', 'ol': 'uk-list uk-list-decimal space-y-2 mb-6 ml-6', 'li': 'leading-relaxed', # Code and quotes 'pre': 'bg-base-200 rounded-lg p-4 mb-6', 'code': 'uk-codespan px-1', 'pre code': 'uk-codespan px-1 block overflow-x-auto', 'blockquote': 'uk-blockquote pl-4 border-l-4 border-primary italic mb-6', # Tables 'table': 'uk-table uk-table-divider uk-table-hover uk-table-small w-full mb-6', 'th': 'text-left p-2', 'td': 'p-2', # Other elements 'hr': 'uk-divider-icon my-8', 'img': 'max-w-full h-auto rounded-lg mb-6' }
Let's try updating just the headings to remove the non-UIkit Tailwind classes:
franken_class_map = { 'h1': 'uk-h1', 'h2': 'uk-h2', 'h3': 'uk-h3', 'h4': 'uk-h4', # Body text and links 'p': 'text-lg leading-relaxed mb-6', 'a': 'uk-link text-primary hover:text-primary-focus underline', # Lists with proper spacing 'ul': 'uk-list uk-list-disc space-y-2 mb-6 ml-6', 'ol': 'uk-list uk-list-decimal space-y-2 mb-6 ml-6', 'li': 'leading-relaxed', # Code and quotes 'pre': 'bg-base-200 rounded-lg p-4 mb-6', 'code': 'uk-codespan px-1', 'pre code': 'uk-codespan px-1 block overflow-x-auto', 'blockquote': 'uk-blockquote pl-4 border-l-4 border-primary italic mb-6', # Tables 'table': 'uk-table uk-table-divider uk-table-hover uk-table-small w-full mb-6', 'th': 'text-left p-2', 'td': 'p-2', # Other elements 'hr': 'uk-divider-icon my-8', 'img': 'max-w-full h-auto rounded-lg mb-6' }
Now explicitly passing in the updated one:
render_md(x, class_map=franken_class_map)
That worked!
render_md
calls apply_classes
:
def apply_classes(html_str:str, # Html string class_map=None, # Class map class_map_mods=None # Class map that will modify the class map map (useful for small changes to a base class map) )->str: # Html string with classes applied "Apply classes to html string" if not html_str: return html_str try: class_map = ifnone(class_map, franken_class_map) if class_map_mods: class_map = {**class_map, **class_map_mods} html_str = html.fromstring(html_str) for selector, classes in class_map.items(): # Handle descendant selectors (e.g., 'pre code') xpath = '//' + '/descendant::'.join(selector.split()) for element in html_str.xpath(xpath): existing_class = element.get('class', '') new_class = f"{existing_class} {classes}".strip() element.set('class', new_class) return etree.tostring(html_str, encoding='unicode', method='html') except etree.ParserError: return html_str
Here, lxml matches and appends the classes after mistletoe generates the HTML. This could probably be improved by implementing a custom Mistletoe HTML renderer instead of parsing its rendered HTML. html_renderer.py looks nicely customizable.
class MonsterHtmlRenderer(HtmlRenderer): def render_heading(self, token: block_token.Heading) -> str: template = '<h{level} class="uk-h{level}">{inner}</h{level}>' inner = self.render_inner(token) return template.format(level=token.level, inner=inner)
Now we try it:
markdown("## Heading 2", MonsterHtmlRenderer)
That worked!
def StyledMd(m): return Safe(markdown(m, MonsterHtmlRenderer))
StyledMd("## Heading 2")
Over 100,000 iterations I dramatically improved Markdown rendering performance:
test_md = "## Heading\n\nParagraph\n\n* List item 1\n* List item 2" def benchmark_old(): return render_md(test_md, class_map=franken_class_map) def benchmark_new(): return markdown(test_md, MonsterHtmlRenderer)
n = 100000
t_old = timeit.timeit('benchmark_old()', globals=globals(), number=n) print(f"Old method: {t_old/n*1000:.2f}ms per iteration")
t_new = timeit.timeit('benchmark_new()', globals=globals(), number=n) print(f"New method: {t_new/n*1000:.2f}ms per iteration")
This is ready to back-integrate into this site. I hope to contribute the relevant parts of this back to MonsterUI as well, to improve Markdown rendering performance in sites that use it.