html-to-markdown
A robust Go library and CLI that converts HTML (even entire websites) into clean, readable Markdown, extensible through a priority-based plugin system.
Repository Health
Technical Analysis
html-to-markdown is a Go module for turning arbitrary HTML into well-formed Markdown, built around a Converter that runs registered handlers across pre-render, render, and post-render phases. Rather than hard-coding a fixed set of tag conversions, the library exposes a Register API so callers can register renderers per tag, mark tags as block/inline/removed, and reorder behavior with priorities — the shipped base and commonmark plugins are themselves built on this same public API, so anything the maintainers can do, consumers can do too.
Beyond the core conversion, it ships an escaping engine that only backslash-escapes Markdown-significant characters when they would actually be misinterpreted, a WithDomain option for rewriting relative links/images to absolute URLs, and optional plugins for GitHub-flavored tables and strikethrough. A companion CLI (html2markdown), distributed via Homebrew, Debian packages, and GoReleaser binaries, wraps the same library for terminal and pipe-based usage — reading files, glob patterns, or piped HTML from curl/echo and writing Markdown straight to stdout or disk.
What You Get
- A
Convertertype withConvertString/ConvertReader/ConvertNodeentry points and aconverter.NewConverter(WithPlugins(...))builder for full control over which plugins are active - A
RegisterAPI (Renderer,RendererFor,TagType,PreRenderer,PostRenderer,TextTransformer,UnEscaper) for writing custom conversion rules without forking the library - Built-in
baseandcommonmarkplugins implementing the CommonMark spec, plus optionaltable(GitHub-Flavored Markdown tables with rowspan/colspan) andstrikethroughplugins - A smart escaping mode that only backslash-escapes characters when they would otherwise be parsed as Markdown syntax, documented in ESCAPING.md
WithDomainsupport for rewriting relative links and image sources into absolute URLs during conversion- A standalone
html2markdownCLI (Homebrew tap, Debian package, prebuilt binaries, orgo install) supporting file input/output, glob patterns, selector-based include/exclude, and plugin flags
Common Use Cases
- Converting scraped web pages or entire crawled sites into Markdown for static-site generators, documentation pipelines, or LLM/RAG ingestion
- Building a ‘export as Markdown’ or ‘copy as Markdown’ feature for a CMS, wiki, or note-taking app that stores content as HTML internally
- Sanitizing/normalizing rich-text editor output (contenteditable HTML) into portable Markdown before storage
- Piping HTML through the
html2markdownCLI in shell scripts or CI jobs to batch-convert files or directories - Writing custom renderers for non-standard or Web Component tags via the
RegisterAPI when the default CommonMark handling isn’t sufficient
Under The Hood
Architecture
The library centers on a Converter struct (converter/converter.go) guarded by a sync.RWMutex, holding prioritized slices of pre-render, render, post-render, text-transform, and un-escape handlers plus a per-tag type map; conversion itself (converter/convert.go’s ConvertNode/ConvertReader/ConvertString) validates that required plugins (base, commonmark) are registered, builds a request-scoped Context carrying domain/URL-assembly state, then runs the three handler phases in priority order before returning the rendered bytes. Extension happens entirely through the Register type (converter/register.go) — RendererFor is a thin wrapper that combines TagType registration with a tag-filtered Renderer, and even the shipped base/commonmark/table/strikethrough plugins under plugin/ are implemented against this same public surface, so there’s no privileged internal API a third-party plugin author lacks access to. The table plugin’s own internal structure (plugin/table/1_select.go, 2_collect.go, 3_render.go) mirrors the library’s own multi-phase philosophy at a smaller scale. A separate cli/html2markdown module wraps the library for terminal use, and internal/ holds non-exported helpers (domutils, escape, textutils) that both the core and plugins depend on, keeping the public API surface deliberately small.
Tech Stack
Written in Go 1.25 (go.mod declares module .../v2, a major-version-in-path signal of a deliberate breaking-change release from v1), with minimal runtime dependencies: golang.org/x/net/html for DOM parsing, github.com/JohannesKaufmann/dom (a companion package by the same author) for node-type helpers, andybalholm/cascadia for CSS selector matching (used by the CLI’s --include-selector/--exclude-selector), agnivade/levenshtein and bmatcuk/doublestar for supporting utilities, and yuin/goldmark plus sebdah/goldie pulled in as test-only golden-file tooling. The CLI is built and distributed via GoReleaser (.goreleaser.yaml), producing Homebrew, Debian/Cloudsmith, and prebuilt binary releases with version/commit/date baked in via ldflags or runtime/debug.ReadBuildInfo fallback.
Code Quality
Testing is unusually rigorous for a library this size: 43 of 129 Go files are test files, and the project uses a custom “golden file” harness (internal/tester/goldenfiles.go, built on sebdah/goldie) where HTML fixtures in testdata/*.in.html are converted and diffed against checked-in *.out.md expected output, regenerable via go test -update — a pattern that scales well to catching conversion regressions across dozens of edge cases. CI (.github/workflows/go.yml) runs the full suite with -race on the latest stable Go, plus a second matrix across Go 1.25/1.26 on Ubuntu, macOS, and Windows. Errors are handled via typed sentinel values (errNoRenderHandlers, errBasePluginMissing) with wrapped context (fmt.Errorf("...: %w", err)) rather than panics, and shared mutable state is consistently protected by the Converter’s mutex with dedicated getter methods that copy-and-sort before returning handler slices. No golangci-lint config or CONTRIBUTING.md is present, but the CI/test discipline substitutes for a good deal of that.
API Design
Getting started requires only three lines — import the package and call htmltomarkdown.ConvertString(html) — while converter.NewConverter(WithPlugins(...)) exposes the full extension surface for callers who need it. Naming is consistent (With* functional options, Register.* for extension points, TagType* constants), and the README pairs every capability with a runnable example under examples/ (basics, options, register) rather than prose alone. The v2 module path change was used specifically to signal the breaking API redesign while leaving a v1 branch intact for existing consumers, and the CLI mirrors the library’s own flag naming (--domain, --exclude-selector, --plugin-table) so switching between the two doesn’t require relearning conventions.