Export a tree to Markdown

Turn a node into GitHub-Flavored Markdown with to_markdown(), so a scraping script ends with Markdown instead of tag soup.

to_markdown() renders a node and its subtree as GitHub-Flavored Markdown (headings, lists, links, emphasis, code, blockquotes, images, and pipe tables), collapsing runs of whitespace the way normal flow lays them out. It is a one-call replacement for the scrapeMarkdown step that html2text or markdownify would do, with no second dependency and the whole walk in C:

import turbohtml

page = turbohtml.parse(
    "<h1>Recipe</h1><p>A <b>quick</b> loaf.</p>"
    "<ul><li>flour</li><li>water</li></ul>"
    "<blockquote><p>Rest 1 hour.</p></blockquote>"
)
print(page.to_markdown())
# Recipe

A **quick** loaf.

- flour
- water

> Rest 1 hour.

Call it on any node to export just that subtree (article.to_markdown()). The output is opinionated GFM: ATX headings, - bullets, fenced code blocks, inline links, and */** emphasis.

A Markdown configuration object covers the markdownify and html2text surface, so a migration reproduces the old output: setext headings, underscore emphasis, reference links, padded tables, alternate escaping, and more. Its knobs are grouped into themed sub-configs (Markdown.Headings, Markdown.Inline, Markdown.Links, …) so no single object is a wall of options. The Migrating to turbohtml guide maps each old option to its turbohtml field.

from turbohtml import Markdown

doc = turbohtml.parse('<h2>Tea</h2><p><b>Steep</b> it. <a href="/x">More</a>.</p>')
print(
    doc.to_markdown(
        Markdown(
            headings=Markdown.Headings(style="setext"),
            inline=Markdown.Inline(strong="__"),
            links=Markdown.Links(style="reference"),
        )
    )
)
Tea
---

__Steep__ it. [More][1].

[1]: /x

The Markdown.Wrapping sub-config shapes the result further. width word-wraps prose at a column (0, the default, leaves paragraphs unwrapped), honoring list and blockquote indentation; list_items extends wrapping into list items and links=False keeps a [text](url) construct on one line. Markdown.Images(mode="html") and Markdown.Tables(mode="html") pass the original <img> or <table> through verbatim, for readers that render embedded HTML. Markdown.Document(transliterate=True) folds common non-ASCII typography in prose (smart quotes, dashes, ellipsis, accented letters) to ASCII:

doc = turbohtml.parse("<p>The “quick” brown fox — jumps over the lazy dog today.</p>")
print(doc.to_markdown(Markdown(wrapping=Markdown.Wrapping(width=30), document=Markdown.Document(transliterate=True))))
The "quick" brown fox -- jumps
over the lazy dog today.

To convert a Google Docs HTML export, use the Markdown.google_doc() preset (or set Markdown.GoogleDoc(enabled=True)) so the inline-CSS styling it carries (font weight, font style, fixed-width fonts, and margin-left list nesting) turns into Markdown:

export = '<p><span style="font-weight:700">Bold</span> and <span style="font-style:italic">soft</span>.</p>'
print(turbohtml.parse(export).to_markdown(Markdown.google_doc()))
**Bold** and *soft*.

When an option cannot express the rule you need (a custom element, or a tag that should render its own way), set the converters field: a mapping from a lowercased tag name to a callable(element, content) -> str. The callable receives the Element and the already-converted Markdown of its children, and returns the Markdown for that element. A registered tag’s built-in rendering is replaced; every other tag is untouched, and the hook costs nothing when the mapping is omitted.

html = '<p>Watch <video src="/clip.mp4">a clip</video> and <abbr title="Markdown">MD</abbr>.</p>'
converters = {
    "video": lambda el, content: f"[{content}]({el.attrs['src']})",
    "abbr": lambda el, content: f"{content} ({el.attrs['title']})",
}
print(turbohtml.parse(html).to_markdown(Markdown(converters=converters)))
Watch [a clip](/clip.mp4) and MD (Markdown).

Return "" to drop an element, or return content unchanged to unwrap it. A registered block-level tag is laid out on its own line; any other tag flows inline. The callable runs inside the same per-tree lock the walk holds, so it may read the element’s attributes and subtree freely.

To unwrap whole tags without a callable, set the strip or convert field, the two mutually exclusive filters markdownify exposes under the same names (passing both raises ValueError when the Markdown is built). strip names tags whose markup is dropped while their text keeps flowing; convert names the only tags to keep markup for, so every other tag is unwrapped. A name the tag table does not know is ignored, and <script>, <style>, and <head> still vanish whole regardless:

doc = turbohtml.parse('<p>see <a href="/x">the docs</a> and <b>note</b> this</p>')
print(doc.to_markdown(Markdown(strip=["a"])))
print(doc.to_markdown(Markdown(convert=["b"])))
see the docs and **note** this
see the docs and **note** this