Changelog
All notable changes to html-to-markdown will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[3.11.1] - 2026-08-15
Section titled “[3.11.1] - 2026-08-15”Upgrade note — the C ABI changed
Section titled “Upgrade note — the C ABI changed”This release changes the C header, so it is not drop-in for C, Go (cgo) or any other consumer that links the native library directly. Rebuild against the new header rather than reusing an existing build. Managed bindings (Python, Node, Ruby, PHP, Java, C#, Elixir, R, WASM) are unaffected.
HTMHtmHtmlVisitorBridgeandHTMHtmHtmlVisitorVTableare gone. Borrowed types that cannot enter the process-global handle registry are no longer exported, so the bridge entry pointshtm_htm_html_visitor_bridge_newandhtm_htm_html_visitor_bridge_freeare removed with them. Use theHTMHtmVisitorCallbacksvtable instead.htm_free_bytesis removed. The exported function count goes from 307 to 304.HTMHtmVisitorCallbacksgains avoid (*free_string)(char*)member, which changes the struct layout. Any code that constructs this struct must be recompiled, and callers that hand out heap strings from a callback should set it so the library releases them with the matching allocator.
The header previously in the tree described a surface the sources had already stopped providing; it is now regenerated from them.
- The generated FFI, Swift and Node sources did not compile, so the native library, the Swift
package and the Node addon could not be built from a checkout. A comment-reflow pass had merged
unsafe {into the preceding// SAFETY:comment at 40 sites in the FFI bridge and collapsed a doc block in the Swift bridge over theSwiftHtmlVisitorWrapperdeclaration, so neither file parsed; the Node addon applied a napigetterto three free functions, which napi allows only inside animplblock. This also blocked every commit to the repository, because the pre-commit hook compiles the staged snapshot and the FFI build script runs cbindgen overlib.rs. - The FFI null-safety test called
htm_htm_html_visitor_bridge_free, a symbol the crate had deliberately stopped exporting, so the test target did not build.
[3.11.0] - 2026-08-13
Section titled “[3.11.0] - 2026-08-13”Upgrade note
Section titled “Upgrade note”Rendered Markdown output changes in this release. The CSS-hidden/hidden-attribute fix below alone
moved 56 of the 116 benchmark oracle snapshots when they were reblessed, and roughly a dozen other
correctness fixes in this range shift output for the documents they affect — 64 of the 116
snapshots changed in total. Consumers who pin byte-exact golden files against this library’s
output should expect to regenerate them when upgrading past this release.
A further 16 snapshots moved when hidden-element detection was corrected to read attribute
structure instead of scanning raw tag text. Every one of those 16 restores content that was
previously deleted: a Wikipedia link whose title attribute contained the word “hidden” was
being stripped in full. If you saw links or sections disappear from converted pages, this is why.
- Documentation snippets are generated from the complete E2E fixture corpus with Alef 0.60.2 and checked for fixture-by-language coverage parity in local tasks and CI; strict validation is scoped to the generated root so the 85 maintained examples remain independently audited.
- The MCP server validates its inputs, bounds its HTTP surface and is instrumented. It previously
had no instrumentation at all despite being a public RPC surface: a failed request returned an
error to the client and left no trace. There are now debug spans on
convert_htmlandextract_metadata(re-entered insidespawn_blocking, which does not inherit the caller’s span), warnings on rejected enum values and conversion errors, and an error event on a panicking blocking task. Behaviorally, unknown enum values from a client are rejected instead of silently falling back to a default, amax_depththat does not fitusizewarns instead of silently becomingusize::MAX, five serialization fallbacks that returnednull/[]on failure now report the failure, and the HTTP transport gained a body-size limit, a concurrency cap, a request timeout, and Origin/Host checks. VisitResultnow serializes with an adjacently tagged, snake_case wire schema ({"type": …, "output": …}), so every binding encodes against one documented shape instead of inventing its own. The field names are public API and a conformance test pins them.
Changed
Section titled “Changed”- Table rendering: each cell’s Markdown is now produced once and reused between the column-width
pre-pass and the render pass, instead of being rendered twice, for the common case (no nested
table, no visitor installed). Rendered Markdown is unchanged — reblessing the benchmark oracle
snapshots before and after produced byte-identical output. This also fixed the metadata
collector recording every element inside a
<td>twice, once per pass, on the default extraction path. Seecrates/html-to-markdown/tests/table_cell_metadata_duplication_test.rs. - Link handling: fewer anchor traversals and allocations per
<a>element. Output-neutral. - Removed the internal
text_contentLRU cache and with it thelrudependency. Because every caller needs an ownedString, a cache hit still cloned, so the cache cost an allocation per miss and saved none; after the table change above, the dominant table path cannot hit it at all. Measured 3.7% faster across the 29-fixture benchmark. Output-neutral. - DOM context building: the per-document context maps (parent, children, sibling-index, and related lookup tables) are now sized once from the arena’s node count up front instead of growing incrementally as new node ids are seen. Output-neutral.
alefis pinned to0.60.2for binding generation, inalef.tomland in the CI lint workflow. The two had drifted apart (0.60.2locally,0.60.1in CI), so CI generated with a different generator than every developer.poly’s whole-project lint phase is now disabled through[workspace.poly] lint-workspaceinalef.tomlrather than a hand-edit to the generatedpoly.toml. The shared CI validate job installs only Rust/Python/Java, so the remaining whole-workspace linters cannot run there; they run via the pre-commit hooks and each language’s own CI job. The previous hand-edit did not survivealef all --clean.- Dependency upgrades, including
base64andtower-httpto their next major versions. html-to-markdown-cligained optionalmimallocandjemallocglobal-allocator features (--features mimalloc/--features jemalloc). Neither is enabled by default; if both are enabled,jemalloctakes precedence, so--all-featuresbuilds continue to work.mcp-httpis no longer a default feature ofhtml-to-markdown-cli. It was on by default, so a CLI installed via brew, cargo, npm or pip shipped the ability to start an unauthenticated HTTP service. Build with--features mcp-httpif you need it; stdiomcpstays on by default, since it opens no listening socket.
-
<br>inside a table cell no longer depends onnewline_styleor on the source HTML’s own whitespace (#453). Two separate defects: withbr_in_tables: falsethe<br>fell through to the paragraph hard-break path and emittednewline_stylebytes into the cell, leaking a literal\underBackslashand a stray extra space underSpaces; and a newline in the source before the<br>survived normalization, so withbr_in_tables: trueit reached the output as a real newline and split the row’s pipe syntax across physical lines, corrupting the table. A cell cannot contain a hard line break, so<br>now collapses to a single space, or renders as a literal<br>whenbr_in_tablesis enabled, in every combination of the two options and regardless of source formatting. -
<div>and<p>continuations inside a table cell follow the same rule as<br>(#454). The two disagreed with each other: a<div>continuation honouredbr_in_tablesbut emittednewline_stylebytes, which are not valid inside a cell, while a<p>continuation always emitted<br>and ignoredbr_in_tablesentirely. Both now emit a literal<br>whenbr_in_tablesis enabled and collapse to a single space otherwise, sharing one code path with the<br>handler. -
A
<code>span inside a table cell no longer corrupts the row (#455). Verbatim content —<code>, and<kbd>/<samp>, which share the same path — skipped cell whitespace handling entirely, so a newline inside the span reached the output and split the row’s pipe syntax across physical lines wheneverbr_in_tableswas enabled. Line breaks in that content are now folded to a single space, independent ofwhitespace_mode, because a raw newline in a cell is a structural impossibility in GFM rather than a formatting preference. All other whitespace, including repeated spaces, is still preserved byte-for-byte, and code spans outside a table cell are unaffected. -
Document-structure and inline-image collectors also record table-cell content exactly once. Images, code blocks and nested tables inside a
<td>were recorded up to three times withinclude_document_structure: true, and inline images twice withextract_images: true. Note the companion change: a table that renders to nothing (a blank table, or one a visitor skips) now contributes nothing todocumentor the inline-image set, matching what it emits;result.tablesstill reports its grid. -
Metadata extracted from inside a table cell (links, images) is now recorded exactly once in every configuration. Previously a link in a
<td>was recorded twice withlink_style: Reference, and three times withinclude_document_structure: true, because the column-width pre-pass, the render pass, and the document-structure grid walk each re-walked the cell through a shared collector. The internal passes no longer record; exactly one walk does. Affectsmetadata.links/metadata.imagescounts for documents with tables — de-duplication on the consumer side is no longer needed. -
Rows with more cells than the header no longer lose them. The separator row was sized from the first row alone, so any later row with more cells had the surplus silently dropped by compliant renderers; it is now sized from the table-wide maximum and every row is padded to match. Separately,
scan_table_nodecounted nested tables and rows transitively across nested<table>boundaries, so the layout-table heuristic misfired for every level of a nested chain except the innermost, turning real table structure into a mix of bullet lists and stray pipe text. -
Consecutive
<li>elements inside a table cell no longer fuse into one word. A cell strips list markers, and nothing separated the items, so<li>a</li><li>b</li>rendered as the fabricated wordab. Both tiers now emit the same<br>boundary already used between sibling<p>/<div>in a cell. -
Content that a browser never renders is no longer emitted.
<template>and<noscript>fell through to the unknown-element handler, which recurses into children and renders their text — a template’s contents are inert per spec and must never appear in the output — andstrip_hidden_elementshonoured only thehiddenattribute, sostyle="display:none"andvisibility:hiddenleaked in full. This is the single largest source of output drift in this release: it alone moved 56 of the 116 benchmark oracle snapshots.aria-hiddenis deliberately untouched, because that content is visually rendered and hidden only from assistive technology. -
Hidden-element detection no longer misreads the tag it is inspecting, in three separate ways.
declaration_hides_elementsplit each declaration on the first:, so a CSS comment ahead of the property name left/* note */ displayas the property and never matched —<div style="/* note */ display:none">SECRET</div>emitted its content; comments are now stripped before the split.tag_has_hidden_attributescanned the raw tag text for the wordhiddenand matched inside quoted values, so a perfectly visible<div title="… hidden from search engines">was deleted whole; it now walksname=valuepairs and matches attribute names. Andtag_has_hidden_styleused.any()over declarations, ignoring the CSS cascade, sodisplay:none; display:blockwas stripped even though the last declaration wins; it now resolves per property. The second of these is why 16 oracle snapshots gained content back — see the Upgrade note. -
Content nested inside a CSS-hidden element (
style="display:none"/visibility:hidden, or thehiddenattribute) no longer leaks into the output when the hidden element contains a nested element of the same tag name (e.g. a hidden<div>containing another<div>). The stripper previously matched the closing tag of the inner element rather than the outer one, so text after the inner close tag — and, for deeper nesting, several more layers of “hidden” text — was emitted as if it were visible. This affects any document with same-tag-name nesting inside a hidden element, including a common Wikipedia infobox pattern; seecrates/html-to-markdown/tests/hidden_element_nesting_test.rsfor the affected shapes. Output changes for documents that hit this pattern. -
Hidden content no longer escapes through a
</tag>sequence that is not really markup. The depth-counting stripper still treated any</tag>byte sequence as a close tag, so four measured shapes leaked against the release binary on default options: a<!-- </div> -->end-of-block marker (an ordinary authoring idiom, requiring no crafting) leaked the trailing text, a quoted attribute value containing</div>leaked, a</div>inside a preservedapplication/ld+jsonraw-text body leaked, and an unbalanced<div>inside a comment inflated the depth counter far enough that the scan overshot and dropped the following visible sibling. The scan now skips comments, CDATA and raw-text bodies, and advances non-target tags through a quote-aware tag-end search. -
Alt text, titles and media URLs are escaped before they are spliced into Markdown. The hardening applied to
<a href>was never propagated to images, graphics or embedded media, so inert input produced live output: an alt ofa](https://evil.example)emitted a real image pointing at that URL;<audio>,<video>and<iframe>usedsrcas both label and destination with no escaping at all;<graphic>had no paren handling whatsoever; and a bare quote in a title closed the Markdown title early, leaving the remainder to parse as document text. The<a>path’sappend_url_destinationandescape_markdown_titleare now shared by all of them, so balanced-paren checking (which had treated)(as balanced) guards image destinations too. Alt and title text containing brackets or quotes is now escaped, which changes output for benign documents as well. -
The HTML serializers are depth-bounded and quote-safe.
serialize_element/serialize_nodein the SVG and utility paths mutually recursed over arbitrary-depth subtrees with no depth parameter at all — a remote stack overflow, reproduced as a SIGABRT with 50k nested<g>or<mrow>— and are reachable from roughly twenty call sites viapreserve_tagsand the visitor’sPreserveHtmlresult. They now truncate with a warning at the native stack-safe depth. Both also re-quoted every reconstructed attribute with double quotes without escaping an embedded one, so a single-quoted attribute holding a literal quote — valid, inert HTML — reconstructed into extra live attributes, turning an inerttitleinto a realonclickhandler. Values are now escaped regardless of the source delimiter. -
Deeply nested documents no longer exhaust the machine. Three separate defects made the depth guard ineffective: roughly two dozen call sites forwarded the recursion depth unchanged while descending into a child, every table-cell walk passed a literal
0and so reset the budget at each cell boundary (a remote stack-overflow SIGSEGV from repeated<table><tr><td>), and two passes outside the depth-bounded walk were quadratic in nesting depth —has_inline_block_misnestran once per block node and walked to the root each time, andscan_table_nodere-walked the whole remaining nested-table chain from every table. A 220KB document of 20k nested<div>s went from 30.5s to 0.06s, and 50k-deep table, div, svg, ol and blockquote payloads — three of which previously ran past two minutes — now all finish in under a second. This was remote resource exhaustion: the guard bounded the walk but not these passes. -
A code block containing a run of three or more backticks — an embedded Markdown sample, say — no longer closes early and corrupts everything after it. The opening fence was hardcoded to three characters; its length is now
max(3, longest_run + 1). Inline code spans use a different rule on purpose: the smallest delimiter length not present in the content, because CommonMark closes a span at a backtick string of the same length, somax_run + 1would over-escape (CommonMark examples 330 and 331). -
An out-of-range
<ol start>no longer panics the whole conversion.startwas parsed asusizewith an unchecked per-item increment, sostart="18446744073709551615"with a single<li>overflowed and failed the entire document rather than just the list. The counter is nowi64, so a negative start counts down as browsers do, out-of-range magnitudes clamp with a warning, and the increment pins ati64::MAXinstead of wrapping. The saturation warning fires once per list rather than once per item, so a long list cannot flood the logs. -
Blockquotes preserve significant leading whitespace. The per-line loop trimmed every content line, so indented code blocks and nested-list continuations inside a
<blockquote>lost their indentation entirely; only whitespace-only lines are blanked now. Nested blockquotes also pushed a hardcoded three newlines regardless of what preceded them, emitting stray blank>lines, and a paragraph following bare inline text inside a blockquote merged onto the same line because the separator was gated off inside blockquotes altogether. The gate is now depth-aware, which leaves the compact heading-then-paragraph style alone (CommonMark example 228 depends on it). -
A list nested inside an ordered list is now indented to its parent marker’s content column instead of a uniform
list_depth * list_indent_width. That uniform width happens to match"- "but not"1. "(3 columns) or"10. "(4), so a nested ordered list was indented 2 columns and CommonMark parsed the child as a sibling of its parent. The indent is now the cumulative width of the ancestors’ own markers, withlist_indent_widthas the floor. -
A panicking visitor callback no longer poisons the handle for every later call. The panic unwound out of
convert()and left the visitor’sMutexpoisoned, so every subsequent conversion reusing that handle failed permanently. The pipeline now runs undercatch_unwindand clears the poison flag, confining the failure to the call that caused it. -
Three inline-image options finally do something.
InlineImageConfig::newseeded its own defaults and the conversion path never overwrote them, socapture_svg,infer_dimensionsandmax_image_sizewere inert despite each having both a builder setter and an update field — and two of those internal defaults were the inverse of the documented ones:capture_svgdocumentedfalsebut always behaved astrue, andinfer_dimensionsdocumentedtruebut always behaved asfalse.max_image_sizewas ignored outright and only ever coincided with the internal constant. Callers who set any of the three will see behavior change to what the documentation always promised. -
The Tier-1 byte-scanner path agrees with Tier-2 again. It carried independently duplicated copies of the code-fence and inline-delimiter bugs fixed above, had no depth ceiling, silently returned an empty
result.metadata, emitted hidden elements that Tier-2 strips, and left link, image and SVG-title labels unescaped — the last being the injection vector Tier-2 has guarded for some time. It also evaluated only one of Tier-2’s three layout-table conditions, so a<table border="0">with acolspan, or two nested tables, produced a GFM table (in the nested case, a malformed one) where Tier-2 produced a bullet list. Tier-1 now bails on both shapes rather than reimplementing them, and the router defers to Tier-2 for metadata rather than maintaining a second collector — a second implementation of that collector is precisely what produced the duplicated fence bugs. Default options never reach Tier-1, so this affects only callers who forceTierStrategy::Tier1or disable metadata extraction. -
Python visitor callbacks now honor the documented
type/outputaction dictionaries for custom link output and imageskip/continueactions ([#452]). -
Dart: the native loader downloads and caches the library again on a cold cache. It only read the versioned cache and then threw a
StateError, even thoughnativeDownloadAndCacheLibrary()was defined and exported for exactly that case — so a machine that had never rundart run h2m:download_libscould not self-heal. The loader also now searches for the_dart-suffixed cdylib that is actually built, opens every candidate by absolute path (a hardened runtime rejects a relativedlopen), walks up fromPlatform.scriptto find the package root as a last resort, and names the real environment variable in its error message instead of printing the identifier$nativeLibDirEnvliterally. Fixed upstream in alef 0.55.6.Behavior change: an unresolvable native now throws a descriptive
StateErrornaming the asset URL and the download command, where it previously returnednulland let flutter_rust_bridge attempt its own relative-pathdlopen— which would fail anyway, but later and less legibly. -
Java:
TierStrategyserializes to the wire names the core actually accepts. Alef 0.55.7 changed the Java backend’s no-rename_allfallback to emit variants verbatim, but it could not seerename_allwhen it sat behind acfg_attrwith anany(...)condition — exactly howTierStrategydeclares it. The generated Java therefore sentAutoto a core that deserializesauto, and every Java conversion failed withunknown variant. Fixed upstream in alef 0.55.8, which parses thecfg_attrcondition structurally. -
CI: the Node e2e job no longer runs the Rust test suite. It invoked
task rust:test(cargo test --release --no-default-features --workspace), a full release-mode build that took 3047s of the job’s 60-minute budget onwindows-latestand leftInstall alefto be cancelled mid-step — the Windows Node e2e job had never once completed.CI Rustalready runs the suite on ubuntu, windows and macos viatask rust:test:ciand compile-checks--no-default-featuresseparately, and no other language’s e2e job ran it. The job now passes in 925s. -
Node: the named ESM re-exports that work around napi’s
module.exports = nativeBindingtail (#450) are now committed incrates/html-to-markdown-node/index.js, not only appended at publish time.cjs-module-lexercannot analyse that tail, soimport { convert } from ...broke for anyone consuming the repo directly. Published packages were already correct — bothtask alef:generateand the publish workflow run the fixup script — but the committed file was not, which also meant a regenerate-and-diff freshness check could never pass. -
--newline-style,--code-block-styleand--bulletsall documented the wrong default in the CLI’s--help, so users following the help text got output they did not expect. Tests now assert the real defaults, so help text and behavior cannot drift apart silently again. -
The CLI’s outbound User-Agent is derived from the crate version. It was a literal pinned at
2.10while the crate shipped3.10.x, so every fetch advertised a version four majors stale. -
The coding-agent plugin’s launcher downloads the right asset and verifies it. It fetched
html-to-markdown-<triple>while releases publishcli-<triple>, so every download 404’d and silently fell through to the slow path — the fast path had never once worked — and it then executed whatever it did fetch, with only TLS vouching for the bytes. It now resolves the checksum fromcli-SHA256SUMS, retries while GitHub propagates release assets, and refuses to execute a binary it cannot verify. -
The Dart, Swift and Zig package READMEs are full documents again (276 / 271 / 260 lines). A partial generator run had replaced all three with fallback stubs of ~30 lines; Dart’s stub was the pub.dev landing page rather than the package README.
-
Internal:
strip_css_commentsuseslet ... elseinstead of a single-armmatch, which was aclippy::single-match-elseerror under-D warningsand failed the workspace clippy gate.
[3.10.6] - 2026-08-05
Section titled “[3.10.6] - 2026-08-05”- Node.js: the
linux-x64-muslandlinux-arm64-muslpackages really are published now. 3.10.5 added them, but both cross-compiles failed to link (cannot find libgcc_s.so.1) and, because the npm publish job requires every matrix leg, no Node package reached the registry at all. The build action exportedCC/CARGO_TARGET_*_LINKERpointing atmusl-gcc, and cargo-zigbuild only sets those when they are unset, so the zig cross-compile was silently replaced by a host-arch musl-gcc that cannot produce a musl cdylib. The musl legs now opt out of that export.
- CI: the musl Node cross-compiles are built on every core/node change instead of only at publish time, and the job fails if a platform package ends up without a native module.
[3.10.5] - 2026-08-05
Section titled “[3.10.5] - 2026-08-05”- Node.js: the
linux-x64-muslandlinux-arm64-muslpackages are now built and published. They were advertised in the main package’soptionalDependenciesbut never produced, so Alpine and other musl installs silently fell back to no native binding, andpnpm install --frozen-lockfilecould not resolve them. - Ruby: the gem no longer publishes its generated types into the global
Objectnamespace, which collided with unrelated libraries (notably theparsergem’sParserconstant). Generated types now stay namespaced underHtmlToMarkdown(tree-sitter-language-pack issue #173). - Documentation: every code snippet in the READMEs, the docs site, and the coding-agent plugin
references was executed against the real API and corrected. The Rust README’s metadata and custom
visitor examples did not compile, the Rust API reference showed
Result<_, Error>instead ofResult<_, ConversionError>, the docs site shipped 102 empty language tabs across five pages, and several bindings’ snippets referenced helpers that no longer exist.
[3.10.4] - 2026-08-04
Section titled “[3.10.4] - 2026-08-04”- Node.js: named ESM imports such as
import { convert } from "@xberg-io/html-to-markdown"now resolve. The napi-generatedindex.jsended withmodule.exports = nativeBinding, which Node’s ESM↔CJS interop (cjs-module-lexer) cannot statically analyze, so named imports threwSyntaxError: The requested module ... does not provide an export named 'convert'. The build now appends explicitmodule.exports.<name> = nativeBinding.<name>re-exports for every public runtime export;require()is unaffected (#450).
[3.10.3] - 2026-08-04
Section titled “[3.10.3] - 2026-08-04”- The Swift package now builds under Xcode/XCBuild, not just
swift build. TheRustBridgeCtarget was header-only, so XCBuild failed to link (RustBridgeC.owas never emitted) for every iOS/macOS consumer of the published SwiftPM package. It now ships a translation unit with an anchor symbol so the object is always produced (#449).
Changed
Section titled “Changed”- The PHP binding sources now live in the
html-to-markdown-phpcrate alongside the other in-crate bindings; the standalonepackages/phplayout has been removed. Composer consumers are unaffected.
[3.10.2] - 2026-08-01
Section titled “[3.10.2] - 2026-08-01”cargo binstall html-to-markdown-clisupport (#448) — prebuilt CLI binaries can now be installed directly from GitHub Releases without compiling from source. Adds[package.metadata.binstall]to the CLI crate plus a release-timeverify-binstallCI job that installs viacargo binstalland smoke-tests the binary.
Changed
Section titled “Changed”- Updated dependencies.
[3.10.1] - 2026-07-31
Section titled “[3.10.1] - 2026-07-31”- The Android AAR (
io.xberg:html-to-markdown-android) now bundles its JNI native libraries. Previously the published AAR contained no.sofiles, so every consumer crashed at runtime withUnsatisfiedLinkError: library "libhtm_jni.so" not found(#446). The publish workflow built the wrong crate (the C-FFIhtml-to-markdown-ffi) and uploaded it from a path the build action never wrote, staging nothing. It now builds the JNI crate (html-to-markdown-rs-jni→libhtm_jni.so) and stages it for every ABI, and a regenerated Gradle guard (alef 0.48.16) fails the build if a correctly-namedlib*_jni.sois ever missing.
- The core library now emits structured
tracingspans and events as a first-class observability surface: anhtml_to_markdown::convertspan (input_len,output_format,wrap,extract_metadata,extract_images,tier_strategyfields) wraps every conversion, withDEBUGevents at parse/walk/render stage boundaries andWARN/ERRORevents on recovered or fatal failures. The library never installs a subscriber — attach one in your application to observe it. The CLI now initializes atracing-subscriber(respectingRUST_LOG, with--debugraising the default level) and routes all diagnostics throughtracinginstead of rawstdout/stderrwrites.
Changed
Section titled “Changed”- Android AAR now ships four ABIs (
arm64-v8a,x86_64,armeabi-v7a,x86). - Upgraded dependencies to latest, including
base640.23 andrmcp3.0.1.
[3.10.0] - 2026-07-30
Section titled “[3.10.0] - 2026-07-30”Changed
Section titled “Changed”- Upgraded the MCP server to
rmcp3.0 (MCP2026-07-28specification). The server now advertises protocol version2026-07-28and negotiates down for older clients, so existing integrations keep working. Minimum supported Rust version is now 1.88.
- Structured tool output (SEP-2106):
convert_html(withjson:true) andextract_metadatanow returnstructuredContentalongside the text JSON, so clients can consume the result as data. - Cache hints (SEP-2549) on the static prompt/resource catalogs (
prompts/list,resources/list,resources/read):ttlMsof one hour with apubliccache scope.
[3.9.2] - 2026-07-27
Section titled “[3.9.2] - 2026-07-27”- Java (Maven Central) and C# (NuGet) publishing, which failed in 3.9.1. Regenerated all language
bindings on alef 0.48.4: the generated pom’s Maven enforcer floor no longer exceeds the CI
runner’s Maven version, and the C# package now renders
runtime.json(from the newly generatedruntime.json.template) beforedotnet packvia thexberg-io/actions/render-runtime-jsonstep.
Changed
Section titled “Changed”- Upgrade dependencies to their latest incompatible versions.
[3.9.1] - 2026-07-26
Section titled “[3.9.1] - 2026-07-26”Changed
Section titled “Changed”- Refine the depth-limit warning (#434): the
DepthLimitExceededmessage now reports the effective limit value, and the warning is emitted exactly once when a deeply-nested DOM is truncated. Thanks @br411 (#428). - Regenerate all language bindings on alef 0.48.2.
- Update dependencies to their latest compatible versions.
Removed
Section titled “Removed”- Remove unused Java PMD ruleset and stale linter configuration.
[3.9.0] - 2026-07-19
Section titled “[3.9.0] - 2026-07-19”- Configurable traversal-depth ceiling (#434): callers may now raise the recursion limit above the
conservative native default (64) by setting an explicit
max_depth, honored up to an internal backstop of 1024. Deeply-nested email HTML that previously lost content past depth 64 now converts. When the limit does truncate a subtree, aDepthLimitExceededwarning is surfaced instead of the content being dropped silently.
<br>in table cells withbr_in_tables(#429): a<br>inside a table cell now emits a literal<br>(valid single-line GFM) instead of a physical newline that broke the row.- Newline-only inline span separator (#430): in normalized whitespace mode a
<span>whose sole content is a newline now collapses to a single separating space instead of being dropped, so adjacent inline text no longer glues together. - Paragraph after a table inside a blockquote (#431): the span newline-pop no longer crosses a table-row boundary, so a following paragraph is not glued onto the delimiter row.
- Intentional hard break inside a span (#432): the span newline-pop no longer eats a
<br>hard break (\n/\\\n), preserving the line break. keep_inline_images_ininside layout-table cells (#433): images inside atd/thlisted inkeep_inline_images_innow stay as markdown when a Tier-2 layout table converts cells as inline, instead of reducing to alt text.
Changed
Section titled “Changed”- Regenerate all bindings with alef 0.37.0.
- Update dependencies across language packages.
[3.8.3] - 2026-07-09
Section titled “[3.8.3] - 2026-07-09”- Java visitor API (#426):
ConversionOptions.builder().withVisitor(...)now works. The visitor upcallFunctionDescriptors were generated with aJAVA_LONGreturn layout while thehandleVisit*bridge methods returnint, so the Java Linker rejected every stub withIllegalArgumentException: Wrong method handle type: (MemorySegment×5)int— even a no-op visitor threw before any callback ran. Fixed upstream in the Alef generator (0.34.4); the descriptor return layout is nowJAVA_INT.
Changed
Section titled “Changed”- Regenerate all bindings with alef 0.34.4.
- Formatting is now poly-only: removed the
alef:format,ruby:format, andcsharp:formattasks (which invokedalef fmt);task format/poly fmt --fix .is the single formatter.
[3.8.0] - 2026-06-27
Section titled “[3.8.0] - 2026-06-27”Stable release promoting 3.8.0-rc.2 (fully published). Version-only bump synced across all manifests.
[3.8.0-rc.2] - 2026-06-27
Section titled “[3.8.0-rc.2] - 2026-06-27”Changed
Section titled “Changed”- Regenerate all bindings with alef 0.29.3.
[3.8.0-rc.1] - 2026-06-26
Section titled “[3.8.0-rc.1] - 2026-06-26”Changed
Section titled “Changed”- Rebrand Kreuzberg → Xberg across every published package identity. The Node binding now ships as
@xberg-io/html-to-markdown(the NAPI-RS crate itself — the separate-nodepackage and the TypeScript wrapper underpackages/typescript/are removed) with platform packages@xberg-io/html-to-markdown-<platform>; WASM and CLI move to@xberg-io/html-to-markdown-wasmand@xberg-io/html-to-markdown-cli. Java/Kotlin Maven coordinates and namespace move toio.xberg(io.xberg:html-to-markdown[-android], JNI symbolsJava_io_xberg_android_…), and the C# NuGet id toXbergIo.HtmlToMarkdown. The GitHub org (github.com/xberg-io), Homebrew tap (xberg-io/homebrew-tap), publisher GitHub App, sponsors links, and all docs/badges follow. The legal entity name Kreuzberg, Inc. is unchanged.
- Swift publish now creates the
release/swift/<version>branch carrying the substituted XCFramework checksum. The alef-generated Swift e2e/test-app pins.package(url: …, branch: "release/swift/<version>"), but the publish workflow only force-moved thev<version>tag and never created that branch, so SwiftPM could not resolve the package. The checksummed commit is now also pushed torefs/heads/release/swift/<version>. (.github/workflows/publish.yaml) - test(test_apps/go): extract the module version with
$NFinstead of$2. The smoke harness’download_ffi.shread the version from$2, which only holds in the blockrequire (…)go.mod form; the inlinerequire <path> <version>form (emitted by the v3.7.2 regen) shifts the version to$NF, so the script built a malformed module-cache path and failed with “Binding directory not found”. This is test-harness only — the published Go package is unaffected.
[3.7.2] - 2026-06-23
Section titled “[3.7.2] - 2026-06-23”- chore(deps): bump
alef.toml.alef_versionto 0.26.6 and regenerate every binding. Fixes the dart wrapper crate failing to build undercargo build --no-default-features: alef 0.26.x had regressed the 0.25.33 fix and re-emitted#[cfg(feature = ...)]on the generatedlib.rsmirror struct / opaque-wrapper declarations, theirFromconversions, andfrom_jsonbridge fns, whilefrb_generated.rsreferences those types/functions unconditionally (E0425: cannot find type VisitorHandle/create_html_metadata_from_json). alef 0.26.6 keeps those declarations unconditional again. Verified: the regenerated dart crate compiles under--no-default-features, default, and--all-features. (alef 0.26.6) - ci(publish): the Homebrew Swift artifactbundle no longer forces
kreuzberg/openssl-vendored. The sharedbuild-swift-artifactbundleaction hardcoded that feature for its Linux (cargo-zigbuild) targets, but it only exists in the kreuzberg core swift crate — so html-to-markdown’s swift bundle build failed (the package '…' does not contain this feature), which cascaded to skiprelease-finalize(and with it thepackages/go/vX.Y.ZGo module tag). The action now takes alinux-featuresinput (left empty here). (sharedxberg-io/actions/build-swift-artifactbundle)
[3.7.1] - 2026-06-23
Section titled “[3.7.1] - 2026-06-23”- ci(homebrew): drop the Intel-macOS
sonomabottle from the publish matrix. The formula intentionally has nox86_64-apple-darwinurl (Apple-Silicon only), but the bottle matrix still built asonoma(Intel) bottle, which failed withformula requires at least a URL. Becauserelease-finalizegates onpublish-homebrew-bottleswithif: !contains(needs.*.result, 'failure'), that one chronic failure silently skipped release-finalize for three releases (v3.6.20, v3.6.21, v3.7.0) — and with it thepackages/go/vX.Y.ZGo module tag, leavinggo get …/packages/go/v3@vX.Y.Zunresolvable. Removing the Intel bottle restores release-finalize (and the Go tag) for every future release. (.github/workflows/publish.yaml) - ci(e2e, C# on Windows): keep
cargoon PATH in the test before-hook. The “Run E2E tests (Windows)” step overrodePATHviaenv:with${{ env.PATH }}, which omits cargo’s$GITHUB_PATHadditions under Git Bash, so thecargo build … html-to-markdown-ffibefore-hook failed withcargo: command not found. Prepend the target dirs to the live$PATHat runtime instead. (.github/workflows/ci-e2e.yaml) - ci(e2e/docs, Elixir): force the NIF to build from source. Under
MIX_ENV=testtheforce_build: … or Mix.env() in [:dev]clause does not apply, somix testtried to download a precompiled NIF for the current (unreleased) version and failed withthe precompiled NIF file does not exist in the checksum file. SetRUSTLER_PRECOMPILED_FORCE_BUILD_ALL=1so the test/doc compile builds the NIF locally. (scripts/ci/elixir/run-tests.sh) - ci(e2e, Swift): build the FFI crate the generated
Package.swiftlinks. The swift e2e before-hook built onlyhtml-to-markdown-rs-swift, but the generated manifest linkshtml_to_markdown_ffi, soswift testfailed withlibrary 'html_to_markdown_ffi' not found. Buildhtml-to-markdown-ffiin the before-hook too (matching Go/C#/C). (alef.toml) - ci(e2e, PHP): drop
--lockedfrom the PHP test before-hook. The PHP e2e job’s extension build rewrites the native package deps to the published registry version and runscargo update, mutating the workspaceCargo.lock; the subsequentcargo build --locked -p html-to-markdown-phpthen failed withcannot update the lock file … --locked. (alef.toml) - ci(node/wasm): use
--no-frozen-lockfilefor the NAPI binding install.build-node-napiran a frozenpnpm install, but the napi platform packages inoptionalDependenciesare pinned to the unpublished release version and can never be inpnpm-lock.yaml, so the install failed withERR_PNPM_OUTDATED_LOCKFILEacross every Node build/e2e. (sharedxberg-io/actions/build-node-napi,alef.toml) - docs(php): recommend
pie installovercomposer require. The PHP package is a nativeext-php-rsextension thatcomposer requirecannot load (the cause of #420); every PHP install snippet now leads withpie install xberg-io/html-to-markdown. (docs/,readme_templates/)
Changed
Section titled “Changed”- ci(publish): require all 16 PHP PIE cells by explicit per-cell pattern in
verify-release-assets. A dropped cell now fails the release instead of silently shipping a partial PIE matrix (#333). (.github/workflows/publish.yaml) - chore(deps): re-pin
alef.toml.alef_versionto 0.26.5 and regenerate every binding, e2e suite, README, and API doc. (alef 0.26.5)
[3.7.0] - 2026-06-22
Section titled “[3.7.0] - 2026-06-22”- feat(mcp): expand the MCP server to full API parity with typed, discoverable options and complete tool annotations. The
convert_htmltool now accepts a typedconfigobject covering every settableConversionOptionsfield (heading/list/escaping/whitespace/wrapping, preprocessing, image extraction, output format, tier strategy, …) instead of an opaque untyped JSON blob, so MCP clients discover all options through the tool’s generatedinputSchema; enum options are accepted as case-insensitive strings parsed by the core parsers. A newextract_metadatatool returns structured<head>/<meta>metadata (title, Open Graph, Twitter Card, JSON-LD/microdata, headers, links, images) as JSON. Both tools carry the full MCP annotation set (title,read_only_hint=true,idempotent_hint=true,destructive_hint=false,open_world_hint=false). The typedConvertConfigmirror is implemented MCP-side (no changes to the alef-tracked core option types) and guarded by a drift test that fails if a core option is added without being mirrored. Themcpfeature now impliesmetadata. (crates/html-to-markdown/src/mcp/) - feat(mcp): add prompts, resources, and completions capabilities. Beyond tools, the server now advertises three more MCP capabilities: prompts — ready-made workflow templates (
convert_to_markdown,extract_main_content,inspect_metadata) that drive the tools, with arguments; resources —htmltomarkdown://options-schema(the JSON Schema of every conversion option) andhtmltomarkdown://output-formats(the markdown/djot/plain guide); and completions — argument autocompletion for prompt arguments (e.g.output_format→ markdown/djot/plain). (crates/html-to-markdown/src/mcp/catalog.rs)
[3.6.21] - 2026-06-22
Section titled “[3.6.21] - 2026-06-22”Changed
Section titled “Changed”- chore(deps): re-pin
alef.toml.alef_versionto 0.25.60 and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.59–0.25.60 generator fixes: the R/extendr by-reference DTO rework now emits a non-optional Named param following an optional param as&T(passed by reference via an ownedname_corebinding) instead of the non-compilingNullable<&T>::into_option()path; Kotlin/Kotlin-Android content-uniontext()accessors reference the actual data-class payload property (value) instead of the non-existentfield0; generated binding rustdoc de-links core intra-doc references (e.g.[`Error::LanguageNotFound`]) to plain code spans sorustdoc -D rustdoc::broken-intra-doc-linkspasses; andsync-versionsnow runs the sameformat_generatedpass asalef all, so version-bumped manifests (package.json,composer.json,Package.swift) are byte-identical to the generate path and no longer trip freshness gates. (alef 0.25.60)
[3.6.20] - 2026-06-21
Section titled “[3.6.20] - 2026-06-21”- chore(precommit): scope the v2.3.0 strict doc/format/lint hooks away from the generated trees. The canonical-hookset bump (v2.3.0, commit
8a5203b7d) enabled several strict hooks that ran against alef-generated code and failed on it. All per-language code underpackages/<lang>/,e2e/, andtest_apps/is generated and ships as alef emits it, so each hook now carries the same generated-tree exclusion the other formatters already use:yard-coverageexcludespackages/ruby/(itsnative.rbholds undocumentable Sorbet-sig RBI defs);air-check/air-formatexcludepackages/(alef formats R with styler, not air);palantir-java-formatexcludespackages/,e2e/,test_apps/, and the vendored.mvn/wrapper. Thelintrhook runslintr::lint_dir(".")and ignores pre-commit file scoping, so its exclusion lives in a new root.lintr(exclusions: list("e2e", "packages", "test_apps"));tsc-typecheckrunstsc --noEmitfrom the repo root, so a roottsconfig.jsonscopes type-checking topackages/typescript/src..yardoc/(yard’s cache side-effect) is gitignored. (.pre-commit-config.yaml,.lintr,tsconfig.json,.gitignore) - chore(precommit): exclude alef-canonical generated files from the formatters that diverge from alef’s own output. alef 0.25.58 made
alef verifyoutput-sensitive and the v2.3.0 hookset addedalef-docs-fresh(which runsalef verify), so any prek formatter that rewrites a generated file differently than alef emits it now breaks verify. Fourteen generated files hit this because the hooks’ pinned tools differ from alef’s:ruff/ruff-format(line-wrapping generated Python),pyproject-fmt(alef runs no pyproject-fmt pass),oxfmt(alef formatscomposer.jsonvia npm oxfmt; the hook uses the oxc binary with a different style),shfmt(generateddownload_ffi.sh/install.sh/gradlew),cargo-sort(the workspace-excluded R and Elixir NIFCargo.toml), andend-of-file-fixer(the Dartfrb_generated.dartandtest_apps__init__.py). Each now carries the generated-tree exclusion the other tools already use, so the files ship exactly as alef emits them andalef verifystays clean. (.pre-commit-config.yaml)
Changed
Section titled “Changed”- chore(deps): re-pin
alef.toml.alef_versionto 0.25.58 and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.55–0.25.58 generator fixes that apply here: the generated Gocmd/download_ffi/main.gotool now carries the standardauto-generated by alefmarker so golangci-lint’sgenerated: laxskips it (0.25.x had dropped its//go:build ignoreguard, exposing inherent-to-a-downloader gosec/errcheck findings); thegenerate/allup-to-date skip now also compares against on-disk output (not just the side cache), so out-of-band drift (agit restore, a hand-edit, an interrupted write) is regenerated rather than silently retained; extract OR-merges cfg gates for same-named types with disjoint gates; R/extendr forwards cfg-gated core features into the generated crate’s[features]table so it builds under-D warnings. The visible churn is the generator’s.d.ts/TS-bridge reformatting (tabs→2-space, double→single quotes). Verified locally:alef verifyis clean and the 3.6.20 bump is synced across all manifests. (alef 0.25.58)
[3.6.19] - 2026-06-20
Section titled “[3.6.19] - 2026-06-20”- test_apps(php): install the PHP extension via PIE’s
pkg:versionsyntax. Both generated PHP test_app installers (test_apps/php/install.sh,test_apps/php_ext/run_tests.sh) ranpie install --version "$VERSION" <pkg>, but PIE parses--version/-Vas “print PIE’s own version” and exits without installing — the pinned extension version was never fetched, so the registry-mode PHP smoke validated whatever stale build happened to be present (or nothing). Usepie install "<pkg>:$VERSION"so the targeted release is actually installed. (alef 0.25.54) - ci(publish): unblock the npm/Node publish under pnpm 11.8.0. The
node-bindingsjob runssetup-node-workspace(which installs with--no-frozen-lockfile) and thenbuild-node-napi, whose ownpnpm install --filterre-enforces CI’s default frozen-lockfile. The napi platformoptionalDependenciesare pinned to the not-yet-published release version, so they cannot be in the lockfile — pnpm 11.6.0 treated the redundant install as a no-op, but the 11.8.0 bump made it fail (ERR_PNPM_OUTDATED_LOCKFILE), dropping every Node build and the npm publish. Passinstall-deps: falseso the already-installed workspace is reused. (.github/workflows/publish.yaml) - ci(homebrew): drop the macOS-Intel CLI block from the formula template. The CLI matrix no longer builds
x86_64-apple-darwin(Apple Silicon only), butscripts/publish/html-to-markdown.rb.tmpl+homebrew.jsonstill referencedcli-x86_64-apple-darwin.tar.gz, so “Update Homebrew formulas” failed (no assets match the file pattern) and the tap stayed on the prior version. Remove the macOSon_intelblock; macOS is now arm-only. (libhtml-to-markdownis unchanged — the C FFI still ships an x86_64-apple-darwin archive.)
Changed
Section titled “Changed”- chore(deps): re-pin
alef.toml.alef_versionto 0.25.54 and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.51–0.25.54 generator fixes: the PHP PIEpkg:versioninstall fix above; Go — unit/newtype-tuple enum constants emit serde wire values, a parameter namedresultno longer collides with the codegen variable, andOption<&[u8]>returns generate correctly; R/extendr — cfg-variant dedup, registration entries drop the stray#[cfg(...)], and opaque-method/Option/Vec/error returns convert properly; C# — corrected host-capsule native method name; Swift —Package.swiftdependency argument order and e2eharness_extrasproduct override; test_apps runner — declared[crates.e2e.env]vars are exported to the run command. Verified locally: both PIE fixes are present in the regenerated test_apps and the 3.6.19 bump is synced across all manifests. - chore(precommit): scope
oxfmt/oxlintaway from generatede2e/+test_apps/. alef has no e2e/test_apps JS/TS formatter (alef.toml [crates.e2e.format]covers go/python/rust/c only), sooxfmtwas the lone tool reformatting generated test JS, producing churn on every regenerate. Add the^(e2e/|test_apps/)exclude thatgo-fmt,ruff-format, andgolangci-lintalready carry; generated test JS now ships as alef emits it, matchinge2e/ruby,e2e/php, etc. (.pre-commit-config.yaml)
[3.6.18] - 2026-06-20
Section titled “[3.6.18] - 2026-06-20”- ci(publish): unbreak Python wheels on every platform. The shared
build-python-wheelsaction source-built libheif 1.23.0 (withlibde265/x265codec headers) for all consumers because kreuzberg linkslibheif-sys. html-to-markdown does not use libheif, but inherited the build — which fails on themanylinux2014(CentOS 7 EOL) base whose yum repos no longer carrylibde265-devel/x265-devel(Error: Not tolerating missing names on install). The action now gates the libheif build behind an opt-inbuild-libheifinput (default off); h2m no longer builds it, so all Python wheels build again. (xberg-io/actions@v1) - ci(publish): re-exclude
php8.5+macos-arm64, restoring PHP publishing. v3.6.17 tried to build the php8.5 Apple-Silicon PIE asset on themacos-14runner (#333), butshivammathur/setup-phpcannot provision PHP 8.5 on arm64 on any macOS runner (the install leaves empty paths —sed: : No such file,/php.ini: No such file). That failing cell skippedupload-php-pie-release, dropping every PHP PIE asset (a v3.6.15-class regression). Restoring the exclusion republishes the PHP extension for all supported targets (PHP 8.2–8.5 on linux/macos-x86_64/windows, plus arm64-darwin for 8.2–8.4). #333 stays open pending an upstream php@8.5 arm64 formula. (.github/workflows/publish.yaml)
Changed
Section titled “Changed”- chore(deps): re-pin
alef.toml.alef_versionto 0.25.50 and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.50 cross-language visitor-codegen fixes that turn the chronically-red e2e visitor suites green: Node — the bridge now reads the externally-tagged{ Custom: ... }/{ Error: ... }payload key (was lowercased tocustom/error, yielding[object Object]); Ruby — visitor callbacks take named params and interpolate{placeholder}templates; PHP — bare-string returns map toCustomfor multi-payload result enums; Elixir — unit-variant atoms match the snake_case wire name (:skipno longer leaks as literal"Skip"); Swift —visitBlockquote’sdepthisUInt, so the override satisfies the protocol. Verified locally: every language e2e suite passes andalef verifyis clean.
[3.6.17] - 2026-06-19
Section titled “[3.6.17] - 2026-06-19”- ci(publish): stage Java natives under the
macos-*RID the loader expects, fixing Java on all macOS. Thejava-nativesmatrix labelled the macOS cellsosx-aarch64/osx-x86_64, which became thenatives/<rid>/directory baked into the jar, but the loader (NativeLib.resolveNativesRid, alefgo_java_platform) expectsmacos-arm64/macos-x86_64. The published jar therefore failed withUnsatisfiedLinkErroron every macOS. Align the labels with the loader. (.github/workflows/publish.yaml) - ci(publish): bundle the Dart native libraries into the published package.
assemble-dart-packageshipped the pub.dev tarball with no native libraries, so the loader fell back to a relative framework path that hardened runtimes reject — the package was unusable for every consumer. Add adart-nativesmatrix that builds thehtml-to-markdown-rs-dartcdylib per platform and stages it intolib/src/native/<rid>/where the loader (_alefHostRid) looks. (.github/workflows/publish.yaml) - ci(publish): build the PHP 8.5 macOS-arm64 extension on the
macos-14runner (#333).shivammathur/setup-phponly provisions PHP 8.5 on Apple Silicon via themacos-14runner, notmacos-latest(15/26). Thephp-extensionmatrix usedmacos-latestand excluded php8.5/macos-arm64, so that PIE asset was never built. Build the macos-arm64 cell onmacos-14and drop the exclusion so thephp8.5-arm64-darwinPIE asset ships. (.github/workflows/publish.yaml) - fix(task): refresh
go.sumin the Go smoke test.test-apps:smoke:gofailed with “missing go.sum entry” after a version bump because alef editsgo.modonly. Run the targetedgo mod download <module>Go suggests — it adds the entry without rewritinggo.mod’s block form, whichdownload_ffi.shparses for the version. (Taskfile.yaml) - test(ci): run the Java e2e step on macOS to cover native RID resolution. The Java e2e ran the e2e step only on ubuntu, so macOS-specific binding/native regressions (such as the RID mismatch above) were never exercised. Run it on macOS too. (
.github/workflows/ci-e2e.yaml)
Changed
Section titled “Changed”- chore(deps): re-pin
alef.toml.alef_versionto 0.25.49 and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.45–0.25.49 generator fixes (incl. kotlin-android AGP 9 toolchain, swift bridge build, content-union accessors). Verified locally:alef verifyis clean andtask alef:formatsucceeds across all languages. - chore(precommit,alef): standardize kotlin-android formatting on ktfmt
--kotlinlang-style. Drop the conflicting prekktlinthook (its always-format mode foughtktfmtover blank-line-after-brace and rewrote alef’s///doc comments to// /, breakingalef verify), switchalef.tomlkotlin-android format/check from gradle-ktlintFormattoktfmtso alef and prek agree, and exclude the vendored Gradle wrapper from shellcheck. detekt remains for static analysis. (.pre-commit-config.yaml,alef.toml) - chore(alef): close formatter-coverage gaps for generated Go and Zig. Add a
gofmt -wformat hook forpackages/go/e2e/go(alef’s Go emitter output needs canonical re-indentation), and extend the Zig formatter/check to coverbuild.zigin addition tosrc. (alef.toml)
[3.6.16] - 2026-06-19
Section titled “[3.6.16] - 2026-06-19”- ci(publish): re-exclude
php8.5+macos-arm64from the PHP extension matrix, restoring PHP publishing. v3.6.15 removed this exclusion to chase the Apple-Silicon PIE asset for #333, butshivammathur/setup-phpstill cannot provision PHP 8.5 onmacos-arm64— the homebrew tap has nophp@8.5arm64 formula, so the cell fails at the setup step with “Could not setup PHP 8.5”. Becauseupload-php-pie-releasedoes not run whenneeds.php-extension.result == 'failure', that single failing cell dropped every PHP PIE asset from the v3.6.15 release (a regression from v3.6.14’s full set). Restoring the exclusion returns the matrix to all-green and republishes the PHP extension for every supported target (PHP 8.2–8.5 on linux/macos-x86_64/windows, plus arm64-darwin for 8.2–8.4). #333 stays open until upstream ships aphp@8.5arm64 formula. (.github/workflows/publish.yaml) - ci(publish): re-publish the Ruby gems missing from v3.6.15. The v3.6.15 run skipped
publish-rubygemsbecause theBuild Ruby gem (windows-x64)cell hung ~3h onsetup-rustand was cancelled, failing theneeds.ruby-gem.result == 'success'gate. This is a transient runner fault, not a code defect; cutting v3.6.16 re-runs the publish so the gems ship.
[3.6.15] - 2026-06-18
Section titled “[3.6.15] - 2026-06-18”- ci(publish): attempted to build the PHP 8.5 + macOS arm64 (Apple Silicon) PIE extension (#333) — ineffective, reverted in 3.6.16. Removed the build-matrix exclusion for
php8.5onmacos-latest, butshivammathur/setup-phpcannot install PHP 8.5 on arm64 (nophp@8.5arm64 homebrew formula), so the cell failed at setup and — becauseupload-php-pie-releaseskips on a failed matrix — dropped all PHP PIE assets from this release. See the 3.6.16 entry. (.github/workflows/publish.yaml) - docs(php): use the native
HtmlToMarkdownApiclass in all PHP examples. The README quick-start, API reference, and docs snippets used theHtmlToMarkdownuserland wrapper, which is only autoloaded via Composer’s PSR-4 and is absent on the PIE install path (extension-only) — soHtmlToMarkdown::convert()raised “Class not found” for users who installed viapie. Examples now callHtmlToMarkdownApi::convert(), the native class registered by the extension itself, which works on both the PIE and Composer install paths (#415). (readme_templates/partials/,docs/snippets/php/,docs/language-guides.md)
Changed
Section titled “Changed”- chore(deps): bump
alef.toml.alef_versionto 0.25.44 (was 0.25.40) and regenerate every binding, e2e suite, README, and API doc. Folds in the 0.25.41–0.25.44 cross-language generator fixes. Verified locally:alef verifyis clean,cargo check --workspace --all-featurescompiles, andprek run --all-files(including the alef freshness hook) passes.
[3.6.14] - 2026-06-18
Section titled “[3.6.14] - 2026-06-18”- chore(deps): bump
alef.toml.alef_versionto 0.25.40 (was 0.25.36) and regenerate every binding. Advances the pin past the[Unreleased]0.25.33 note to a tagged, published alef revision and folds in the 0.25.34–0.25.40 cross-language fixes. Binding-visible changes: Java drops the throwingUnsupportedOperationExceptionDTO/enum-method stubs (NodeContext.withOwnedAttributes/intoOwned,ConversionOptions.defaultInstance,PreprocessingOptions.defaultInstance) — there is no JNI symbol for these yet, so the stubs compiled but threw at runtime; absence is safer than a misleading throw until DTO marshaling lands (boxed-Booleanserde defaults also restored). Swift emits a matchingpub fn visitor_handle_noopdefinition for the bridge no-op declaration so the swift rust crate compiles (wasE0425under 0.25.36’s decl-without-def), alongside the upstream streaming-owner declaration rework. Node (NAPI) map-returning functions now convert borrowed maps to ownedHashMapinstead of returning them bare (fixes a would-beE0308from the new DTO-method emission). Python (pyo3) drops a redundant# type: ignore[arg-type]on the visitor assignment thatmypy --strict(warn_unused_ignores) rejected, and None-guards the coercion comprehension for optionalVec<enum>fields. Verified locally:task alef:generateis fresh (alef verify), every workspace binding crate compiles, andprek run --all-filesis clean. - ci(publish):
upload-php-pie-releasenow hard-errors if zerophp-package-*artifacts reach the aggregator instead of producing an empty PIE upload. The download step addsif-no-files-found: error, so a future regression that drops all PHP build outputs surfaces immediately instead of silently shipping a release with no PIE assets — the failure mode behind v3.6.11’s missing-asset reports (see #333). The matrix-cell tolerance from v3.6.12 (needs.php-extension.result != 'cancelled' && != 'skipped') is unchanged. (.github/workflows/publish.yaml) - ci(task rust:test): re-include
html-to-markdown-rs-dartin the--no-default-features --workspacesweep. The provisional exclusion added earlier in this[Unreleased]cycle is now unnecessary: alef 0.25.33 + the queued[Unreleased]follow-up drop#[cfg(feature = ...)]from every dart-wrapper mirror declaration and the bidirectionalFromimpls, so the wrapper crate compiles cleanly under default,--no-default-features, and--all-features. Verified locally against a freshtask alef:generate. (.task/languages/rust.yml) - chore(deps): bump
alef.toml.alef_versionto 0.25.33 and regenerate every binding. Sweeps in the cross-language fixes accumulated through 0.25.30–0.25.33 plus the (still-[Unreleased]) dart cfg follow-up that the regen incorporates locally: pyo3convert(...)/ConversionOptions(__init__)visitor kwarg widened toHtmlVisitor | object | None(resolves #403); swift options-field factory exported asmake{Trait}Handle(matches docs snippets + e2e generator); java record + builder PMD cleanup (per-instance non-finalfields, redundant component docs removed); kotlin file-level@file:Suppressextended withReturnCount+NestedBlockDepthon the shared emitter; codegenVec<core::T>→Vec<wrapper::T>conversion on non-opaque method returns (pyo3, magnus, extendr); wasmOption<Vec<UnitEnum>>getter/setter emission; php untagged-data-enum delegation viaserde_json::from_value; extract honors#[cfg_attr(alef, alef(skip))]onimplblocks (no more duplicate#[no_mangle]symbols on builder + field name collisions); cfg-gated public function re-exports treated as binding surface; extendrextendr_module!registration entries gated on the sourceFunctionDefcfg; dart mirror enum + struct declarations + bidirectionalFromimpls all unconditional now sofrb_generated.rsresolves against the local crate regardless of the dart wrapper’s feature set. Pin will advance again to a tagged alef revision once the[Unreleased]dart follow-up + extendr fix ship in 0.25.34.
[3.6.13] - 2026-06-17
Section titled “[3.6.13] - 2026-06-17”- style(test_apps/go):
shfmt-normalizedownload_ffi.shso CI Lint’sshfmthook stops rejecting the v3.6.12 baseline. The hand-written cgo library stager shipped with v3.6.12 used inline case-arm bodies; CI’sshfmt -i 2 -ci -bn -sprofile splits them onto separate lines. CI Lint failed on every v3.6.12 commit until the file was normalized. - (via alef 0.25.26–0.25.28) Generated bindings pick up: PHP shared lossy and enum-tainted binding-to-core helpers now skip
binding_excludedfields and emit..Default::default()so core types with customDefaultimpls (e.g. anything that pulls config from the environment) are no longer shadowed by zeroed field defaults across Python/Node/Ruby/WASM/extendr/PHP method bodies and From-impls; FFI same-name function dedup moved out of the shared extractor pass into a backend-local pass (backends::ffi::gen_bindings::functions::cfg_dedup::dedup_same_name_functions) so every other backend and the e2e call-export validator see the original multi-entry surface untouched (v0.25.26 had over-collapsed the shared surface and strippedalef(skip)-tagged siblings from every backend); FRB Dart bridge functions wrapping core functions that return primitives now emit the cross-type cast (e.g..map(|v| v as i64)) instead of the redundant.map(|v| v)that v0.25.25–0.25.27 emitted; generated Kotlin Android files addReturnCountto the@file:Suppresslist so detekt no longer fails sealed-class deserializers with 3+ variants. - (via alef 0.25.27) Generated Swift bindings emit the factory function as
make{Trait}Handle(matching the documented spec, the docs snippets underdocs/snippets/swift/visitor/, and the e2e test generator) instead ofmake{Trait}{TypeAlias}(which silently doubled the trait stem tomakeHtmlVisitorVisitorHandle); generated Swift e2e visitor closures qualify the context type with the host module (HtmlToMarkdown.NodeContext) so the unqualified reference is no longer ambiguous againstRustBridge.NodeContextthat the test file also imports. - (via alef 0.25.27) Generated TypeScript WASM e2e configs override
SsrfPolicy.denyPrivate = falsewhen the binding exposes the field, since WASM has nostd::env::varandSsrfPolicy::from_env()always falls back todeny_private = true, which rejected the localhost mock-server requests every WASM e2e fixture targets.
Changed
Section titled “Changed”- Bump alef pin to 0.25.28 (was 0.25.25), regenerating all binding outputs, e2e codegen, API reference docs, and test_apps scaffolds.
[3.6.12] - 2026-06-17
Section titled “[3.6.12] - 2026-06-17”- test_apps/go: stage cgo FFI library into the binding’s module-cache
.lib/<platform>/so smoke tests link cleanly. The published Go binding declares// #cgo LDFLAGS: -L${SRCDIR}/.lib/<platform>/ -lhtml_to_markdown_ffi, but${SRCDIR}resolves to the binding’s source directory underGOMODCACHE, which is empty aftergo mod download. The package’s owncmd/download_ffiis//go:build ignore-tagged and only invocable via the binding’s owngo generate, which test_apps doesn’t run. Fix:test_apps/go/download_ffi.shreadsMODULE_VERSIONfromgo.mod, downloads the matchinghtml-to-markdown-rs-ffi-v<version>-<rust-triple>.tar.gzartifact from the GitHub release, caches it under${XDG_CACHE_HOME:-~/.cache}/html-to-markdown-ffi/, makes the binding’s module-cache subtree writable, and copies the library into<binding>/.lib/<platform>/. The smoke task invokes the script beforego test. Restores Go to the registry-mode smoke matrix. - ci(publish): replace single-success gate on
upload-php-pie-releasewith cancelled/skipped guards so partial matrix success still publishes the available PIE archives. The job was gated onneeds.php-extension.result == 'success', which meant a single transient matrix-cell failure (e.g. theENOTFOUNDartifact-upload flake observed on the v3.6.11 publish run) cascaded into skipping the entire PIE upload step. With the matrix already declaredfail-fast: falseand the in-jobactions/download-artifact@v4step configured withpattern: php-package-*+merge-multiple: true, the upload now proceeds whenever at least one matrix cell produced an artifact. Transient flakes no longer block the entire release surface. - (via alef 0.25.20–0.25.25) Generated binding suites pick up: NAPI TypeScript
import { A as B }→ CJS{ A: B }rename translation (sooxfmtno longer aborts onExpected ',' or '}' but found 'as'); Go FFIcopyLibraryToBindingPackageno-op when source aliases destination (avoids 0-bytelibhtml_to_markdown_ffi.dylibaftergo generate); SwiftPackage.swiftv__ALEF_SWIFT_VERSION__placeholder substitution at scaffold-write time so SwiftPM consumers don’t 404 against the release asset URL; Homebrewrun_tests.shformula-installed CLI preflight using the parameterized binary name; alef post-generation formatter pipeline aligned with downstream prek hooks (ktfmtfor Kotlin format,gofmt+goimportsfor Go,oxfmtfor TS/JSON,shfmtfor shell scripts,php-cs-fixerfor PHP,cargo sortfor emitted Cargo.toml). Resolves CI Lint formatter divergence that had been red on every commit since v3.6.11. - (via alef 0.25.25) Generated PHP test_apps
install.shemitspie install --version "$VERSION" "<pkg>"instead of the deprecatedpie install "<pkg>:$VERSION"form that PIE 1.4.5+ rejects withUnable to find an installable package <pkg> for version <ver>. Restores PHP to the registry-mode smoke matrix. - (via alef 0.25.25) Generated
[crates.dart] excluded_default_features/[crates.swift] excluded_default_featureskeep optional cargo features out of the wrapper’sdefault = [...]array while still declaring them as opt-in forwarding entries, preventing target-conditional cross-compile activation of features whose system deps aren’t cross-compile-ready. - (via alef 0.25.25) Generated publish/vendor flow retries
cargo update+cargo metadataon crates.io registry-index propagation lag (up to 6 attempts × 30 s) so per-language build jobs don’t hard-fail in the first few minutes afterPublish Rust cratescompletes.
Changed
Section titled “Changed”- Bump alef pin to 0.25.25 (was 0.25.19), regenerating all binding outputs, e2e codegen, API reference docs, and test_apps scaffolds.
[3.6.11] - 2026-06-16
Section titled “[3.6.11] - 2026-06-16”-
Table layout pre-pass: skip nested-table rendering during column-width measurement (issue #406, residual fix). The
MAX_CELL_WIDTH = 200cap shipped in v3.6.10 (4013a6864) bounded the discarded output but not the measurement CPU. For deeply nested layout HTML (e.g. the reporter’s Outlook digest: 393<table>tags, 851 KB),cell_text_contentstill recursively dispatchedhandle_table_with_contexton every nested table during the outer measurement pre-pass, triggering its own pre-pass on every descendant cell — combinatorial explosion (cells × nested_cells × ...) unbounded at greater nesting depth. The reproducer still ran for tens of minutes on v3.6.10. The fix threadsmeasure_width_only: boolthrough the conversionContext;walk_node’s"table"arm short-circuits to descendant text content when set, keeping the pre-pass linear in descendant character count. The reproducer now converts in 0.04 s. Regression testnested_layout_tables_convert_within_wall_clock_budgetcovers a synthetic 4×4 nested-cell fixture with a 10s wall-clock budget. Tier-1 vs Tier-2 separator-row dash counts now diverge on the nested-table fallback (Tier-1 still measures the rendered cell text); existing tier-divergence tests were updated to assert outer-row content equality instead of byte-equality. Resolves the residual #406 report fromhobofanon 2026-06-16. -
(via alef 0.25.19) Generated Swift app harness migrates fixtures JSON from triple-quote multi-line string literal to chunked-array
[...].joined(), avoiding Swift’smulti-line string literal content must begin on a new lineerror when the Jinja whitespace-trim placed content on the same line as the opening""". -
(via alef 0.25.19) Generated FFI service-API codegen clones borrowed opaque-pointer params at the call site so consuming Rust APIs that take
Tby value receive an owned value while the C caller retains the original handle for its own_freecall. -
(via alef 0.25.19) Generated csharp e2e csproj template branches
<RuntimeIdentifier>onOSArchitecture, pickingosx-arm64/linux-arm64/win-arm64on arm64 runners instead of hardcoded*-x64. -
(via alef 0.25.19) Generated magnus binding
Cargo.tomlemits a cfg-feature forwarding[features]block so any#[cfg(feature = "X")]arms in the binding compile under-D warnings.
- (via alef 0.25.19) Generated language doc pages translate Rust type spellings to language-native terminology in prose (Python
list[X]/dict, TypeScriptX[]/Record, Go[]X/map, etc.), applied viamap_non_code_linesso code fences stay intact.
[3.6.10] - 2026-06-16
Section titled “[3.6.10] - 2026-06-16”-
Table column-width: cap per-cell measurement at 200 characters (issue #406). Converting Microsoft-style HTML emails with hundreds of nested layout tables produced runaway output (73 MB / 2.2 s for the reporter’s 851 KB reproducer) because
collect_row_cell_widthsused the rendered width of each cell — including any inner table’s separator rows — as the outer column width. The outer table’s separator was then emitted with that many dashes, which fed back into the grandparent’s measurement, doubling at every nesting level. Capping the per-cell measurement atMAX_CELL_WIDTH = 200bounds total output toO(cells × 200)and keeps both the Tier-1 fast path and the Tier-2 walker numerically consistent. The reproducer now converts in 0.47 s producing 218 KB of markdown. Bisected by the reporter to alef-drivenArc<Mutex<>>visitor migration in 3.4.1 (7f6178f25); the v3.6 series already fixed the visitor-mutex hot loop in4863d1ab6, but the underlying exponential-width feedback remained. Regression test added:deeply_nested_layout_tables_do_not_produce_runaway_output. -
Bump alef to 0.25.18. Picks up: (a) the e2e visitor codegen fixes for Node and C# — Node tests now pass
{ visitor: V as any }throughoptions.visitorrather than as a silently-dropped third positional argument, C# tests merge into the existingnew ConversionOptions()literal rather than appending a fourth arg, restoring everyCustom-substitution visitor test. (b) The Zig e2e build.zig fix dropping the duplicated_runsuffix in test-sequencingdependOncalls (conversion_run_run→conversion_run), unblocking Test: Zig. (c) The Swift box-delegate cast fix removing the spuriousInt(...)wrap onusize/isizeargs so the bridge call matches theUIntprotocol declaration, unblocking Test: Swift. (d) The Ruby Rakefile modifier-iffix forcross_compile_versionsassignment, removing theStyle/IfUnlessModifierrubocop offense that broke all 4 Test: Ruby matrix jobs. (e) The PHP PIE URL{OSLower}placeholder fix sopie install xberg-io/html-to-markdownresolves the correct asset name (resolves h2m #333). (f) The binding-crate feature passthrough block emission in FFI/Node/PHP/WasmCargo.toml— each binding crate now declaresmetadata,visitor,inline-images,testkitas passthrough features forwarding tohtml-to-markdown-rs/X, fixing theunexpected cfg condition valueerrors underRUSTFLAGS="-D warnings"for code paths gated by core features. (g) The swift-bridge owner-type extern block + cfg-gating fix preventing proc-macro expansion failures on cfg-disabled types. (h) The JNI trait-useemission fix so Tier-B Rust-public trait extension points are reachable from the generated JNI shims.
[3.6.9] - 2026-06-15
Section titled “[3.6.9] - 2026-06-15”- Bump alef to 0.25.17. Picks up the dart
unreachable_patternsallow attribute for the crate-root, which was missing from the dartgen_rust_cratescaffold. v3.6.8’s CI Rust failed withunreachable patternerrors atpackages/dart/rust/src/lib.rs:1739and:2102: the dart enum-conversion path emits an_ => unreachable!("cfg-gated variant ... not active in this build")catch-all so the match remains exhaustive when a#[cfg(feature = "X")]-gated variant is compiled out, but the dart binding’s[features]table forwardstestkitunconditionally, so the cfg-gated arm IS compiled in and the catch-all is unreachable —-D warningsturned the lint into an error. alef 0.25.17 addsunreachable_patternsto the existing#![allow(unused_variables, unreachable_code)]crate-root attribute, matching the swift backend’s already-correct allow list.
[3.6.8] - 2026-06-15
Section titled “[3.6.8] - 2026-06-15”- Bump alef to 0.25.16. Picks up the vendor
manifest_abscanonicalize fix (resolves Elixir NIF / PHP extension / Ruby gem / Python sdistalef publish preparefailures from the v3.6.7 publish run), the Dartcfg(testkit)whitespace fix (unblocksValidate: RustandTest: Darton main), the rustler.clone()skip for reference parameters, the JNI workspace-target host fix, the extendr enum type-path resolver, the swiftFrom<core>cfg-gating for variant arms, the FFI visitor enum-context i32 emission, the visitor_result bare-stringCustomrouting, the e2e csharp SDK-AssemblyInfo suppression, prerelease set-version support (0.25.10–11); the swift cfg-union postprocessing fixes for default-build wrapper-type emission and thedefault = [<features>]Cargo.toml emission (0.25.12–15); and the drop of cfg propagation on enumFrom-impl match arms — binding crates do not declare gated features themselves but pull the core dependency with them enabled, so propagating#[cfg(feature = "testkit")]to binding-side arms producedunexpected cfg condition value: testkiterrors under-D warningson py/node bindings (0.25.16). - C# assembly identity now tracks
<Version>. Deleted the hand-committedpackages/csharp/HtmlToMarkdown/Properties/AssemblyInfo.cs(carrying a staleAssemblyVersion("3.4.0")) and switched the scaffold to let the .NET SDK deriveAssemblyVersion,AssemblyFileVersion, andAssemblyInformationalVersionfrom<Version>plus the new<Company>/<Product>MSBuild properties. The published 3.4.0 assembly identity was breakingtask test-apps:smoke:csharpagainst every NuGet package since the original ship.
Changed
Section titled “Changed”- CI E2E PHP/Ruby builds: re-enable
rewrite-native-deps. The workaround in 3.6.7 (a8c7fe40c) disabledrewrite-native-depson PHP and Rubybuild-*actions to dodge the alef 0.25.9 canonicalize bug. With 0.25.11’s fix the action works correctly again, so the fourrewrite-native-deps: "false"overrides are removed.
[3.6.7] - 2026-06-15
Section titled “[3.6.7] - 2026-06-15”-
Restore Dart binding. v3.6.6 silently dropped Dart from
[workspace].languagesinalef.tomland movedpackages/dart/rustfrom workspacememberstoexcludein the rootCargo.toml. The pub.dev publish ofh2m3.6.6 succeeded only because the Dart workflow’s tag-time checkout predated the exclusion; any subsequent regeneration would have orphaned the binding. Dart is restored to both lists. -
Bump alef to 0.25.9 — fixes Elixir NIF
[patch.crates-io]no-op error blocking the v3.6.6 publish. alef 0.25.7’s Elixir scaffold emitted an unconditional[patch.crates-io]block in the generated Rustler NIFCargo.tomlwith entries shapedalloc-no-stdlib = { version = "=2.0.4" }— cargo rejects these aspatch for 'alloc-no-stdlib' points to the same source, but patches must point to different sources, failing every Elixir NIF matrix cell (linux x86_64/aarch64, macos x86_64/arm64) plus the Hex publish job on v3.6.6 publish run 27510348278. alef 0.25.9 (commite1e86bbc1) replaces the broken patch block with direct[dependencies]entries pinningalloc-no-stdlib = "=2.0.4",alloc-stdlib = "=0.2.2",brotli-decompressor = "=5.0.1"plus matchingcargo-macheteignores. 0.25.9 also ships the Ruby Rakefile YARD-coverage fix (55f1eccf6).
[3.6.6] - 2026-06-14
Section titled “[3.6.6] - 2026-06-14”- Bump alef to 0.25.7 — fixes
alef publish preparefor non-workspace-member NIF crates (Ruby gem, Elixir NIF). alef 0.25.4–0.25.6 had a bug wherecargo update --lockedandcargo metadata --lockedwould fail for binding crates that are not workspace members (like the Rustler NIF and Magnus gem). The seed lockfile lacked a[[package]]entry for the binding crate, causing cargo to reject it. alef 0.25.7 dropped--lockedfrom the metadata validation step and set.env_remove("CARGO_BUILD_LOCKED")to allow cargo to resolve these crates from the registry. This fix restores Ruby (all platforms) and Elixir NIF (all platforms + Hex package) support.
[3.6.5] - 2026-06-14
Section titled “[3.6.5] - 2026-06-14”-
Elixir Hex/NIF + Ruby gem publish: bump alef pin to 0.25.4 so
alef publish prepareproduces a valid binding lockfile. alef 0.25.1’svendor::scrub_or_regenerate_lockstrict-mode path failed on every v3.6.4 Elixir NIF and macOS/Linux Ruby gem build withcargo update -p <lockfile> (or final cargo metadata validation) failed (exit code 101) ... cannot update the lock file ... because --locked was passed— the seed lockfile’s workspace-member path entries collided with the registry-source entries the rewrite added, and the finalcargo metadata --lockedvalidation could not reconcile them. alef 0.25.3 addedstrip_workspace_member_entriesto drop the path-source entries from the seed before per-membercargo update -pruns, plus a full registry-URL package-id spec (registry+https://github.com/rust-lang/crates.io-index#NAME@VERSION) to disambiguate the per-member update. 0.25.4 also escapes Rust reserved keywords in extendr struct fields (relevant to R bindings consuming flat data enums withserde(tag = "type")). -
Python sdist on Alpine/musl:
actions/rewrite-native-depsv1.8.69 now stripspath = "..."from[workspace.dependencies]entries. The 3.6.4 sdist’s rootCargo.tomlshipped with[workspace.dependencies] html-to-markdown-rs = { version = "3.6.4", path = "crates/html-to-markdown" }, and cargo eagerly validates every workspace-dep entry onpip install— bailing withfailed to read .../crates/html-to-markdown/Cargo.tomlbecause the workspace crate is not bundled in the sdist. v1.8.66 added[patch.*]stripping for sdist consumers (resolved #390) but missed[workspace.dependencies]. The new step dropspathfrom every workspace-dependency entry, leaving the version so the dep resolves from crates.io on consumer install. Resolves #402.
[3.6.4] - 2026-06-14
Section titled “[3.6.4] - 2026-06-14”-
CLI: drop
reqwestbrotlifeature to keepalloc-no-stdlibon 2.x. The transitivebrotlicrate (pulled in viaasync-compression) jumped to 8.x which expectsalloc-no-stdlib3.x, but the workspace resolves the sibling at 2.x. The resulting trait drift brokecargo buildon stable rustc withthe trait bound 'StandardAlloc: alloc::Allocator<...>' is not satisfied, blocking the ubuntu-latest Python wheel build during the v3.6.3 publish run. The CLI’s HTTP client now uses gzip+deflate only. -
Publish workflow: dispatch
publish-pubdevwith the release tag, not the branch ref. Whenpublish.yamlwas triggered byrelease: published,github.ref_nameresolved to the branch where the release was authored (e.g.main). The child workflow’s OIDC token then carriedrefType=branch, which pub.dev rejects withpublishing is only allowed from 'tag' refType. Dispatch now usesneeds.prepare.outputs.tagso the child runs against the tag and the OIDC token is accepted. -
Taskfile:
test-apps:test:zignow grep’d the rootCargo.tomlfor the workspace version. The previous grep targetedcrates/html-to-markdown/Cargo.toml, which usesversion.workspace = trueand has no literal version line —H2M_VERSIONresolved to empty and the zig-fetch URL collapsed to…/download/v/html-to-markdown-rs-zig-v.tar.gz(404).
Changed
Section titled “Changed”-
Track
Cargo.lockfor reproducible builds.Cargo.lockwas previously gitignored, defeatingcargo build --lockedeverywhere it was used. Every CI runner resolved deps fresh, allowing semver-compatible drift to silently introduce broken transitives (the brotli/alloc-no-stdlib mismatch above). The root workspace lockfile plus the per-package nested lockfiles (R, Ruby, Elixir NIF, e2e/rust, test_apps/rust) are now tracked. -
Pass
--lockedtocargo buildin CI, publish, scripts, andalef.tomlhooks. Now that the lockfile is tracked, every CI/publish cargo build runs with--lockedso a dirty index can’t silently substitute newer transitive deps. Local-dev tasks (.task/languages/rust.yml) intentionally remain without--lockedso contributors can pick up dep updates. -
Regenerated cross-language bindings with alef 0.25.1. Pulls in: csharp e2e codegen using
KreuzbergConverterfacade; swift e2e codegen no longer emits?chains on non-OptionalRustStringreturns; C e2e codegen panics on missingfields_c_typeskeys instead of silently miscompiling; FFI/NAPI/PyO3/Magnus/Rustler test surface cleanup;vendor::scrub_or_regenerate_lockpreserves workspace lockfile pins via per-membercargo update+cargo metadata --lockedvalidation; NAPI strips thereadonlykeyword from emittedservice.cjs.
[3.6.3] - 2026-06-14
Section titled “[3.6.3] - 2026-06-14”Changed
Section titled “Changed”- alef extension API:
[crates.ffi].visitor_callbacksis now backed by the new alef per-extension config mechanism. alef’sparse_confighook now receives the real[extensions.<name>]section fromalef.tomlrather than alwaysNone. Thevisitor_callbacks = trueknob in[crates.ffi]remains the correct way to enable the visitor/callback FFI pattern — it is a general alef feature shared with Go, Java, C#, and other FFI consumers, not h2m-specific. A newtransform_emitted_fileshook on theExtensiontrait is also available for downstream post- processing of generated files; h2m does not currently use it but can opt in without alef changes.
- CI:
upload-go-releasenow gates onrelease_go == 'true'. The job’s conditional was missing the explicitneeds.prepare.outputs.release_go == 'true'check, allowing it to run during partial publishes when Go is intentionally skipped. Without this guard, skipped Go builds would still trigger FFI uploads/finalize-release’s go-module-path tagging. Now properly skips when Go release is disabled.
[3.6.2] - 2026-06-13
Section titled “[3.6.2] - 2026-06-13”-
Python
.pyi:ConversionOptions.visitorattribute type now resolves toHtmlVisitor | None. The class-attribute annotation pass in alef’s pyo3 stub emitter previously printed the opaqueVisitorHandleconcrete type forOption<dyn HtmlVisitor>fields, while__init__parameters andconvert(...)already resolved to theHtmlVisitorProtocol. Field-style assignment (options.visitor = MyVisitor()) is now type-clean under pyright/pylance. Closes #403. -
NAPI
convert: visitor callbacks now fire when passed viaoptions.visitor. The generatedconvert(html, options)shim previously declared a third standalonevisitorparameter and used it directly while ignoringoptions.visitor. The TypeScript surface advertises a single uniform entry point —convert(html, { visitor: { … } })— so visitor callbacks routed throughoptionswere silently dropped. Fixed in alef v0.24.16; the standalone parameter is gone andoptions.visitoris the sole source. Closes #395. -
NAPI options-field bridge: drop unused
muton the closure binding. The generatedconvert(html, options)shim’sOption<JsConversionOptions>.map(|o| …)closure movedostraight intoo.into()without mutating the binding, somut otriggeredwarn(unused_mut)oncrates/html-to-markdown-node/src/lib.rs. Fixed in alef v0.24.17 by droppingmutfrom the napi-side closure binding; the wasm-side template is unchanged because it does mutateo.visitorbefore theinto()call. -
Ruby: precompiled-gem ABI fallback to source-gem. The
cross_compile_versionslist in the Magnus Rakefile template now targets Ruby 3.5/3.4/3.3/3.2 (dropping 3.1, adding 3.5), and the gemspecrequired_ruby_versionbounds the upper edge with>= 3.2.0, < 4.0. On Ruby 4.0+ / 4.1.0dev, RubyGems now refuses the precompiled platform gem and falls back to the source gem, eliminatingincompatible ABI version of binaryload failures on prerelease ABIs. Closes #405, #409. -
C# / Kotlin Android: wrapper class renamed
HtmlToMarkdownRs→HtmlToMarkdownConverter. The previous…Rssuffix was Rust-implementation-bleed in the public binding surface. The new name is idiomatic for both ecosystems and matches the names already used in our hand-written docs. BREAKING for downstream C# / Kotlin-Android consumers: applys/HtmlToMarkdownRs/HtmlToMarkdownConverter/gto source code that imported or invoked the wrapper. Closes #408. -
PHP PIE: macOS install resolves the extension at the archive root.
pie install xberg-io/html-to-markdownon macOS arm64 previously failed because the staged extension was namedhtml_to_markdown.sowhile PIE’sUnixBuildprobes for<extname>.<dylib_ext>on macOS. The publish workflow now stages the extension ashtml_to_markdown.dylibon macOS targets and.soon Linux, restoring PIE installs on Apple Silicon and Intel Macs. Closes #334. -
Python wheel:
HtmlVisitortrait-bridge runtime-import resolved. alef’s pyo3 trait-marker-class emitter creates_Trait{Name}Markertypes in the DTO module (visitor.rs) to satisfy TypeScript/Ruby/etc. downstream trait surface generation. The PyO3 bridge uses these markers as import-path sentinels, but h2m’sConversionOptionsvisitor field only became public in v3.6.1 after its prior conditional#[cfg(feature = "visitor")]gate was removed. Wheels shipped without the marker import working, causingImportError: cannot import name '_TraitHtmlVisitorMarker'at runtime when user code calledconvert(..., visitor). Fixed in alef v0.21.0 (marker-import path resolved from the actual DTO module). -
Java JAR:
NativeLibRID alignment with publish-workflow classifier names. The publish workflow (publish.yaml) uploads native libraries with RID classifiers:osx-aarch64,osx-x86_64,linux-x86_64,linux-aarch64,windows-x64. TheNativeLib.scala.ktloader looked forosx-arm64andosx-x86-64(mismatched case and infix), breaking native library resolution at JAR initialization. Fixed in alef v0.20.5; RID names now match the publish assets exactly. -
R tarball: GitHub-release install fixed. The CRAN tarball sources from GitHub release tags when users install from
.tar.gzsource (non-CRAN edge case). Theconfigurescript rancargowithCARGO_HOME=...override to redirect vendor offline access, butcargo vendoritself does not honorCARGO_HOME(only cargo build does), so the offline build failed with “could not findhtml-to-markdownin the registry”. extendr 0.18.1 correctly passes--registry crates-io --offlinethrough tocargo vendor, restoring offline builds. The configure script also fixed inboundstr_as_strimports from extendr that the v0.18.0 landing initially broke. -
Homebrew:
brew trustprerequisite documented. Homebrew 6.0+ (released 2026-05) requiresbrew trust xberg-io/tapbefore installing from the third-party tap. Updateddocs/installation.md,README.md, andtest_apps/homebrew/run_tests.shwith the trust step and explanatory note. Smoke test now callsbrew trust "$TAP" || truebeforebrew bundle install, making CI-tested on Homebrew 6.0+ environments.
- CITATION.cff
date-released:auto-stamped.alef sync-versions --release-date YYYY-MM-DDnow stamps the release date directly into the workspace-generatedCITATION.cff, eliminating the prior hand-edit step at release-cut time. The release date for v3.6.2 is2026-06-13. Closes #327.
[3.6.1] - 2026-06-12
Section titled “[3.6.1] - 2026-06-12”-
Ruby gem:
Rakefilereverted to v3.5.5 working pattern. v3.6.0’s alef 0.21.0 regen introducedRbSys::ExtensionTaskwith manualDir.chdirnesting that broke nestedfile "Cargo.lock"task declarations. Reverts to the v3.5.5Rake::ExtensionTaskpattern for all 5 platforms (macOS arm64/x86_64, Linux x86_64/aarch64, Windows x64). Resolves bundle exec compilation failures. -
Zig: curl HTTP/2 support. Zig’s vendored curl bindings required
+h2feature flag for HTTPS URL fetch operations. Updatedpackages/zig/build.zig.zonto enable the feature, restoring GitHub-release downloads on Zig consumers. -
Homebrew bottle ordering stabilized. alef v0.20.5 sort order for bottle entries now matches
brew auditexpectations; formulae no longer fail the Homebrew official-tap checklist.
[3.6.0] - 2026-06-12
Section titled “[3.6.0] - 2026-06-12”-
Tiered HTML-to-Markdown conversion architecture. Clean HTML inputs now have an opt-in fast path through a Tier-1 single-pass byte scanner (
converter/tier1/); on anything the scanner cannot prove byte-equivalent to the existing Tier-2 DOM walker, it returns a structured bail and the dispatcher falls back to Tier-2 (tl::parse+walk_node) transparently. Output is always byte-equal to what Tier-2 would have produced for the same input — verified by 116 oracle snapshots and a per-fixture byte-equality integration test (tests/tier1_byte_equality_test.rs). Tier-3 (html5everrepair) remains the fallback for truly malformed HTML. -
ConversionOptions::tier_strategy(TierStrategy::Auto|Tier2|Tier1) for runtime tier selection.Auto(default) lets the classifier decide based on the prescan signals and options shape;Tier2forces the existing DOM-walk path;Tier1is testkit-only (#[cfg(any(test, feature = "testkit"))]) for debugging and benchmarking. Exposed to Node (tierStrategy: "auto" | "tier2") and Wasm (WasmTierStrategyenum). CLI and Python pick up the field via the standard options-mapping flow. -
Tier-1 router style-option gate. The classifier forces Tier-2 when any of these deviate from Tier-1’s hardcoded value:
heading_style,code_block_style,strong_em_symbol,bullets,list_indent_*,whitespace_mode,newline_style,escape_*flags,output_format,link_style,url_escape_style,compact_tables,default_title,sub_symbol,sup_symbol,highlight_style. 47 integration tests enforce the gate. -
Tier-1 conservative bail set. Tier-1 returns
Err(BailReason::*)rather than emit potentially-wrong output on: custom elements, CDATA, unescaped<, inline SVG, HTML 5 optional-close edge cases,<table>with rowspan/colspan/block-children-in-cells/ caption/mixed-section-order, nested lists (Tier-2 cycles bullets by depth),<pre>with non-Indentedcode_block_style, named HTML entities outside the 45-entry zero-alloc table, table cells containing|, and<br>inside table cells. Tests cover every variant. -
Benchmark harness (
tools/benchmark-harness/, binaryhtmbench) withrun/compare/oracle/oracle:bless/survey/mdreamsubcommands. Per-group regression guardrails (baselines/baseline.json,guardrails.json). Wired undertask bench:*namespace. -
Bench regression gate in CI — every PR that touches the Rust core or bench harness now runs
task bench:oracle && task bench:run && task bench:compareonubuntu-24.04-arm, failing on any fixture that exceeds its per-group threshold intools/benchmark-harness/guardrails.json(5% clean_large, 8% clean_medium, 10% other groups, 30% adversarial). Baselines are blessed deliberately by humans, not automatically by CI.
Changed
Section titled “Changed”-
Publish workflow now authenticates via the
kreuzberg-dev-publisherGitHub App (org-levelBOT_APP_ID/BOT_APP_PRIVATE_KEY) for all writes (own-repo pushes, tag updates, release-asset uploads, homebrew-tap commits, Discord dedup marker). OIDC publisher jobs (PyPI/npm/hex/Maven/NuGet/crates) are unaffected.HOMEBREW_TOKENis no longer used. -
BREAKING:
NodeContext::attributesis no longer a public field. Access attributes via the newattributes()method (fn attributes(&self) -> &BTreeMap<String, String>). Struct-literal construction ofNodeContextis no longer possible; use the provided constructors:with_borrowed_attributes,with_owned_attributes(public), orwith_lazy_attributes(pub(crate), for internal use only). Attributes are now lazily materialized from the DOM on first access when constructed via the internal path, eliminating the per-elementBTreeMapallocation on the visitor hot path. Measured throughput improvement with visitor enabled (100-iteration harness, Apple Silicon): small_html −29%, medium_python −10%, large_rust −24%, tables_countries −24%. -
DomContext::parent_tag_namenow returnsOption<&str>instead ofOption<String>. This is an internal API (pub(crate)); external consumers are unaffected.
Performance
Section titled “Performance”-
Tier-1 byte scanner activated in production (
tier_strategy = Auto). The classifier decides per-input whether Tier-1’s single-pass byte scanner runs; on bail, the dispatcher falls back to Tier-2 transparently. Measured throughput on the harness corpus (29 fixtures, 6.4 MB total; Apple Silicon,cargo build --release):Fixture Size ms (best) Throughput real-world/wikipedia/medium_python.html1.24 MB 62.58 ms 19.0 MB/s real-world/wikipedia/large_rust.html1.07 MB 37.17 ms 27.3 MB/s mdream/github-markdown-complete.html430 KB 10.57 ms 38.7 MB/s mdream/react-learn.html265 KB 12.11 ms 20.9 MB/s mdream/wikipedia-small.html166 KB 5.63 ms 28.1 MB/s real-world/issues/gh-121-hacker-news.html57 KB 1.08 ms 50.3 MB/s mdream/nuxt-example.html3.6 KB 0.029 ms 116.1 MB/s Per-group regression thresholds (5–30%) are enforced on every PR via
task bench:compare(see Added section). -
memchr-driven text scan:
decode_and_collapse_intoanddecode_entities_intousememchr::memchr3/memchr::memchrto skip ahead to the next special byte (<,&, whitespace boundary) and bulk-copy plain text runs in a singlepush_str. Replaces a byte-by-byte conditional inner loop and closes a substantial portion of the gap to main’s heavily-optimized Tier-2 path on Wikipedia-scale documents (e.g., +32% onwikipedia-small). -
Tier-1 dispatcher reuses normalized input on Tier-2 fallback. When the Tier-1 scanner bails or the classifier routes to Tier-2, the dispatcher threads the already-computed
normalize_inputCow<str>through to the Tier-2 path instead of recomputing it. Eliminates one full-input pass on every bail-fallback or Tier-2-routed call. -
collapse_excess_blank_linesin-place. Replaced the fresh-Stringrewrite withString::retain, eliminating an output-sized allocation on every Tier-1 success whose output contains\n\n\n. Measured wins (best-of-3,--force-tier1, vs prior commit):wikipedia/lists_timeline-3.81%,gh-121-hacker-news-4.23%,github-markdown-complete-2.99%,wikipedia/medium_python-2.94%,mdream/wikipedia-small-2.68%,mdn-array-2.12%,gh-190/firsteigen-2.55%. No fixture regressed. -
htmbench --force-tier2flag mirroring--force-tier1, for clean head-to-head benchmarks now that the Auto router activates Tier-1 on most fixtures and so cannot be used as a Tier-2 control. -
convert()accepts options as a bareConversionOptionsin addition toOption<ConversionOptions>(resolves #398). The second parameter now boundsimpl Into<Option<ConversionOptions>>, soconvert(html, opts),convert(html, Some(opts)),convert(html, None), andconvert(html, ConversionOptions::default())are all valid Rust call shapes. Existing callers continue to compile unchanged — this is purely additive flexibility, not a breaking change. The ~250-line conversion body is held in a private non-genericconvert_innerso the generic wrapper monomorphises exactly once per call site rather than perIntoimpl chosen. -
url_escape_styleoption (UrlEscapeStyle::Angle|UrlEscapeStyle::Percent). When set toPercent, link and image destinations are percent-encoded instead of wrapped in angle brackets, producing output that all Markdown parsers handle correctly even when the URL contains<,>, spaces, or parentheses (resolves #392).
-
Non-deterministic SVG attribute serialization in
converter/media/svg.rs:serialize_elementiteratedtag.attributes()over astral-tl’s internalHashMap, which has non-deterministic iteration order. SVGdata:URIs therefore differed across runs. Fixed by sorting attributes by name before emission, restoring determinism. -
spurious blank lines after frontmatter and lists (MD012) (resolves #399). Block-level emission now collapses runs of three or more consecutive newlines into exactly two, so the frontmatter→body and list→next-block transitions no longer produce extra blank lines that violate markdownlint MD012.
-
autolinks: bare paths and filenames are no longer wrapped as autolinks (resolves #397). Per GFM §6.5, autolinks require an absolute URI with a scheme — but the previous check only compared the link text to the
href, so<a href="foobar.png">foobar.png</a>became the invalid<foobar.png>(which parsers read as a literal HTML tag). Addedhas_uri_schemehelper that validates the RFC 3986 scheme grammar (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )followed by:). Bare paths, fragments, and filenames now render as[text](href).https://,mailto:,ftp://,data:, and other schemed URLs continue to autolink as before. -
node binding: visitor callbacks (
visitText,visitLink,visitHeading, …) now fire (resolves #395).JsHtmlVisitorBridgenow stores a persistentnapi::bindgen_prelude::ObjectRef<false>obtained viaObject::create_ref()and materializes a fresh localObjecthandle per callback throughobj_ref.get_value(&env). The previous bridge stored rawnapi_valuepointers extracted viatransmute_copyand reconstructed viaObject::from_raw— but anapi_valueis a local handle tied to the HandleScope active at construction time, and by the time visitor methods fired deep insideconvert()the scope was no longer active, soget_named_property("visitText")silently returnedErrand the bridge fell through toVisitResult::Continuewithout dispatching. ADropimpl on the bridge callsobj_ref.unref(&env)so the JS object can be GC’d after conversion. Verified against the issue repro:visitText/visitLink/visitHeadingall fire. (alef commit1ffdaafe4) -
code block:
CodeBlockStyle::BackticksandTildesno longer emit a trailing blank line inside the fence and now insert a blank line after the closing fence (resolves #396). The fenced emitter pushedcontentverbatim (which already ends in\nfor any trailing-newline source) plus an extra\nbefore the closing fence, producing…\n\n```\n. It also closed with a single\n, so following block content butted up against the closing fence with no blank line separator —Indentedstyle was unaffected because its content path strips trailing newlines via.lines().join("\n")and already emits\n\nafter. The Backticks/Tildes path now trims trailing newlines from inner content (content.trim_end_matches('\n')) before re-emitting a single\n, and pushes\n\nafter the closing fence to match Indented. -
php test_app: PIE
install.shnow installsxberg-io/html-to-markdowninstead of the non-existentxberg-io/html-to-markdown-rs(resolves #98 / smoke regression). The Packagist project only publishes the un-suffixed name;alef.tomlwas renamed and the regen picks it up in bothinstall.shand the inline comment. -
r binding:
conversion_options()helper exported inNAMESPACE. alef’s R emitter generated theR/options.Rhelper for ergonomic ConversionOptions construction but never exported it, sohtmltomarkdown::conversion_options(...)raisedcould not find functionat runtime — which downstreamconvert(..., options=)callers then surfaced as the extendr-api 0.9.0options must be a named listvalidation error. Resolves #99 / smoke regression. (alef commit160d504f3) -
java binding:
NativeLib.<clinit>no longer throwsNoSuchElementExceptionwhen an optional FFI symbol is missing. The error-context handle lookups (LAST_ERROR_CODE,LAST_ERROR_CONTEXT) usedOptional.orElseThrow()mid-static-init, escalating any partial symbol set into a hardNoClassDefFoundError; they now fall back tonulland the rest of the loader’s null-checks handle the absence gracefully. Resolves #100 / smoke regression. (alef commitd296bfdeb) -
c FFI test_app
Makefilenow embeds anLC_RPATHso the smoke binary can find the dylib at runtime on macOS withoutDYLD_LIBRARY_PATH. Adds-Wl,-rpath,$(FFI_LIB_DIR)to the LDFLAGS path. Resolves #101 / smoke regression. -
csharp binding: trait-bridge catch blocks no longer emit unused
Exception exvariables. The generatedTraitBridges.cshad 25+catch (Exception ex)blocks whereexwas never referenced; with<TreatWarningsAsErrors>true</TreatWarningsAsErrors>the build failed with CS0168, which in turn blocked NuGet publish for 3 releases (root cause of #104). alef now emits barecatch (Exception)when the exception variable isn’t actually used. (alef commit16e81b20d)
CI/Publish
Section titled “CI/Publish”-
.npmrcat workspace root:minimum-release-age=0so the npmBuild Node bindingsandBuild WASM packagejobs stop failing atpnpm installtime withERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATIONfor transitively-recent dep pins. The v3.5.5 fix only patched the test command; this covers everypnpm installcall repo-wide. Resolves #96. -
Go subtag publish (
packages/go/v3.5.x) is already wired through thexberg-io/actionsfinalize-releasejob (publish.yaml:2418 passesgo-module-path: packages/go/v3); no action needed here. The reason v3.5.2–v3.5.7 didn’t push subtags is upstream main-release-job failures cascading. Resolves #97.
- alef pin: 0.20.12 → 0.21.0 (local unreleased head; no alef tag). Bindings + e2e suites regenerated against the freshly-rebuilt local alef binary, picking up the cohort fixes above in a single regen.
[3.5.7] - 2026-05-29
Section titled “[3.5.7] - 2026-05-29”- release: sync workspace
Cargo.tomlversion. v3.5.6’s release commit (2c37942f8) claimed to bump the workspace version 3.5.5 → 3.5.6 but the change never actually landed in the commit. Every binding manifest (Cargo.toml, pyproject.toml, gemspec, mix.exs, pom.xml, .csproj, package.json, …) stayed pinned at 3.5.5, so the v3.5.6 publish workflow built v3.5.5 binaries against the v3.5.6 tag and uploaded them as*-v3.5.5-*.tar.gzassets to the v3.5.6 GitHub Release. crates.io / PyPI / Hex / RubyGems / Maven publishes were either no-ops (3.5.5 already exists) or rejected; npm + WASM + NuGet + Homebrew steps also failed. v3.5.6 is skipped; v3.5.7 is the corrected ship of every fix originally rolled into v3.5.6. - alef pin: 0.20.11 → 0.20.12 (auto-synced by
alef sync-versions).
- bindings: regenerated against alef v0.20.12 with workspace version correctly bumped to 3.5.7 first, so every manifest now matches the release tag.
[3.5.5] - 2026-05-28
Section titled “[3.5.5] - 2026-05-28”- ci(ruby): cross-compile
windows-x64gem viabundle installinside therb-sys-dockcontainer. The previous matrix entry ranrb-sys-dock --platform ... -- bundle exec rake "native[$target]" gem, which invokedbundle execinside the container without first materialising the lockfile against the container’s Ruby version. The rb-sys-dock container ships its own Ruby toolchain (4.0.2 preview) while the hostGemfile.lockpins 3.3.11, so bundler failed with “Could not find …” and hung for 47 minutes. The command now wraps the container invocation inbash -c "bundle install --jobs=4 --retry=3 && bundle exec rake …"so the lockfile materialises against the container’s Ruby beforerakeruns. (.github/workflows/publish.yaml) - ci(elixir): Hex publish no longer 404s on darwin tarball downloads. alef v0.20.2 changed darwin NIF tarballs to
.dylib.tar.gzto “match the platform-native extension,” butrustler_precompiled 0.9.0(the latest version on Hex; no.dylib-aware version exists) hardcodes.sofor every non-Windows consumer download URL inlib_name_with_ext/2and ignores all caller overrides. h2m’s hand-maintained publish.yaml had already normalised darwin uploads to.so, so h2m’s own Hex publish kept working; the bug only surfaced in sibling polyglot repos. The alef v0.20.5 revert (.dylib→.so) restores polyrepo alignment and is picked up by this regen. (alef.tomlpin →v0.20.5,xberg-io/actions/generate-elixir-checksums@v1) - bindings(c): registry test_app
download_ffi.shnow hits the correct release asset prefix. The[crates.e2e.registry.packages.c] namewashtml-to-markdown-ffi, butpublish.yamluploads C FFI tarballs withasset-prefix: html-to-markdown-rs-ffi-. Drift causedtask test-apps:smoke:cto 404 on the GH release tarball. (alef.toml) - bindings(java): registry test_app now ships the Maven wrapper (
mvnw,mvnw.cmd,.mvn/wrapper/maven-wrapper.properties). Previously missing because mvnw emission landed in alef v0.20.2 and h2m was pinned at v0.20.1. (alef.tomlpin →v0.20.5) - bindings(homebrew, c, …): every alef-emitted
*.sh(e.g.run_tests.sh,download_ffi.sh) is now created with the+xbit set. alef’swrite_scaffold_files_with_overwrite()skipped the shebang-chmod helper thatwrite_files()applied, so every alef-generated shell script ine2e/andtest_apps/landed as-rw-r--r--, breakingtask test-apps:smoke:homebrewwith “permission denied” on./run_tests.sh. Fixed by alef v0.20.3’s sharedapply_shebang_chmod()helper called from both writers. (alef.tomlpin →v0.20.5) - bindings(go, php, swift, csharp, dart, kotlin_android, zig, …): trait-bridge codegen cohort fixes. PHP refcount safety (inc_count/dec_count via PhpRc), Go duplicate //export removal + cgo.Handle.Delete defer/recover, Swift async/throws + try await order + JSON-encode conditionally, C# callback method naming + bool→int + IntPtr userData + usize/isize mapping, Dart trait-import + factory wrapper required-only methods + RID-aware published-loader, Kotlin Android useJUnitPlatform() in registry mode, Zig error-union removal from test-backend stubs, Java trait-method emission incl. default-impl methods +
ffi_skip_methodshonouring inI{Trait}interface emission. (alef.tomlpin →v0.20.5) - bindings(elixir): rustler upgraded 0.37 → 0.38. (
packages/elixir/native/html_to_markdown_nif/Cargo.toml) - bindings(php): PHP trait-bridge async no longer panics on “Cannot start a runtime from within a runtime.” Generated async method bodies now prefer
Handle::try_current()beforeWORKER_RUNTIME.block_on(...)so the bridge is safe to call from within an outer tokio runtime. (alef.tomlpin →v0.20.5) - bindings(wasm, node): test_apps pass
--config.minimumReleaseAge=0topnpm testso freshly-published RC packages clear pnpm’s supply-chain policy check. (alef.tomlpin →v0.20.5)
- bindings: regenerated against alef v0.20.5. Folds in every fix from v0.20.2 through v0.20.5 — see alef CHANGELOG for the full backend cohort.
- deps(java): jackson-databind + jackson-datatype-jdk8 2.21.2 → 2.21.3. (
packages/java/pom.xml) - deps(rust): reqwest 0.13.3 → 0.13.4 in CLI binary. (
crates/html-to-markdown-cli/Cargo.toml) - deps(js): pnpm packageManager 11.3.0 → 11.4.0. (
package.json) - taskfile(elixir): tolerate
mix hex.outdatednon-zero exits beforemix deps.update --all. (.task/languages/elixir.yml)
[3.5.4] - 2026-05-28
Section titled “[3.5.4] - 2026-05-28”- ci(node): restore alef-generated
index.d.tsafternapi buildin thenode-typescript-defspublish job. The napi-rs macros deliberately filter out the*UpdateDTO types (ConversionOptionsUpdate,PreprocessingOptionsUpdate), so the d.ts regenerated bynapi buildlacks them; the wrapper atpackages/typescript/src/index.tsre-exports those names andtscfails with TS2724 on every publish. The publish job nowgit checkout HEAD -- crates/html-to-markdown-node/index.d.tsbetweennapi buildand the artifact copy, preserving alef’s augmented d.ts. - ci(ruby): cross-compile
windows-x64gem viarake-compiler-dockfrom the Linux runner. The previous matrix entry’scargo build --target x86_64-pc-windows-gnuagainst the ucrt64 MSYS2 toolchain corrupted a linker argument and failed every release withcannot find -l■. Linux-host docker-basedrake-compiler-dockis rb-sys’s recommended cross-build path and matches what every other rb-sys gem uses for Windows. - bindings(elixir):
RustlerPrecompilednow resolves the correct platform extension on macOS. v0.20.0’s rustler emit lets RustlerPrecompiled’s built-in mapping pick.dylibfor*-apple-darwintargets and.sofor*-linux-*, somix deps.getno longer 404s against*.so.tar.gzon darwin consumers. - alef hash: per-file
alef:hash:now computed over generation inputs, not emitted content. Post-format whitespace drift no longer invalidatesalef verify, so CI Lint stops reporting bindings as stale after every prek--all-filescycle. - bindings(java): test_app pom.xml drops
-Djava.library.path=.../target/releasein registrydep_mode. The Maven-Central JAR bundles natives under/natives/{rid}/and alef’s loader extracts them at startup; overriding the library path to a non-existent local directory brokeUnsatisfiedLinkErroron smoke runs. Override is now emitted only in localdep_mode. - bindings(c): test_app Makefile no longer requires a local
target/release/build to resolve the FFI.download_ffi.shis skipped only when both the FFI header and shared library are present locally; otherwise the published tarball is fetched. Smoke runs from a clean checkout work without a priorcargo build. - bindings(php): registry test_app installs the PIE-distributed extension via
install.shbeforecomposer install. Removes the implicit “ext-html_to_markdown must be pre-installed” requirement.
- bindings: regenerated against alef v0.20.1. Folds in B6 hash semantics, java/c e2e build-path fixes, php install.sh, vendored Cargo workspace marker, scaffold persistence (rustc job cap + Node memory cap), wasm e2e import dedupe, service_api emitter family for csharp/dart/go/swift/zig/jni/ffi/extendr/napi/magnus/php/rustler/pyo3 with real dispatch (not yet wired in h2m but available for downstream features), zig platform-suffixed URLs in build.zig.zon, kotlin nullable bare result assertions, and php anonymous-class super_trait
name()dedup.
[3.5.3] - 2026-05-27
Section titled “[3.5.3] - 2026-05-27”- core: prevent silent truncation on XHTML-style self-closing tags (
<td/>,<br/>, etc.) embedded in EPUB-derived HTML. The bundled astral-tl parser treats/as an identifier character, so<td/>was parsed as a tag literally named"td/"and subsequent siblings nested under it — table rows collapsed into a single cell and any content after the broken table was dropped.convert_api::normalize_inputnow preprocesses<tag/>→<tag />before tokenization so the trailing slash is read as a self-closing marker. Regression covered bytests/issue_391_xhtml_self_closing.rs. Closes #391. - docs(python): visitor API reference rewritten to match the actual duck-typed binding. The generated
docs/reference/api-python.mddocumented aclass MyVisitor(VisitorHandle):subclass pattern withVisitResult.Continueenum returns, butVisitorHandleis#[pyclass(unsendable, from_py_object)](sealed; cannot be subclassed) andVisitResultexposes only property getters (VisitResult.ContinueraisesAttributeError). The trait rustdoc onHtmlVisitornow leads with the plain-class + string/dict return idiom that the magnus-style bridge actually accepts. Closes #389. - packaging(python): unblock PyPI sdist build from Alpine/musl and other no-precompiled-wheel platforms. PyPI 3.5.1’s sdist was missing the core
crates/html-to-markdown/directory because a stale.gitmodulesentry pointing at a deletedhomebrew-tapsubmodule causedcargo package --listto fail silently during the CI maturin run, so maturin only bundled the PyO3 crate and shipped a sdist that could not build. Removed the stale.gitmodules; maturin’s path-dependency auto-bundling now works again and the resulting sdist contains all 177 core-crate files. Closes #390. - ci(swift): Swift Artifact Bundle publish job uses the renamed crate
html-to-markdown-rs-swift. Stalehtml-to-markdown-swiftreference broke the Swift bundle step on every release since v3.5.0.
- bindings: regenerated against alef v0.19.22. Pulls in the e2e green-up cohort (csharp/php/extendr/rustler/swift/zig/dart/go/java/ruby/elixir trait-bridge emitter fixes) and drops alef’s broken
[tool.maturin] includedirective (maturin rejects archive paths with..).
[3.5.2] - 2026-05-26
Section titled “[3.5.2] - 2026-05-26”-
bindings(csharp): trait-bridge facade methods now throw the per-binding
HtmlToMarkdownRsExceptioninstead of an undefinedKreuzbergException. The alef csharp backend’s register/unregister facade method emitter was hardcoded toKreuzbergException(the kreuzberg core lib’s exception class), causing every C# build to fail withCS0246: KreuzbergException could not be found. Fixed in alef 0.19.13 and pulled in by this regen. -
bindings(swift):
RustBridgeC.hplaceholder now declares theRustStrC struct thatSwiftBridgeCore.swiftdepends on. Without the typedef the Swift compiler reportedcannot find type 'RustStr' in scopefor everyextension RustStrblock before the fullcargo buildpopulated the real header. Fixed in alef 0.19.13. -
bindings(swift): resolve Swift Package Manager “unsafe build flags” rejection for v3.5.2+. Swift 6.0+ strictly rejects packages with
unsafeFlagsin public products to prevent supply-chain code injection. The rootPackage.swiftused by external consumers (via.package(url: "...", from: "...")) is now removed from git; it will be regenerated at release time with.binaryTargetpointing to pre-built xcframework/artifactbundle assets. For v3.5.2+, consumers will receive binary distribution (no Rust compilation required). For source builds, developers usecd packages/swift && swift buildaftercargo build -p html-to-markdown-rs-swift. The in-treepackages/swift/Package.swiftretainsunsafeFlagsfor local development. This fixes the blocking error for v3.5.1 external consumers attempting to depend on the package.
[3.5.1] - 2026-05-25
Section titled “[3.5.1] - 2026-05-25”-
bindings(ruby): expose
ConversionOptions.visitorand wire the magnus visitor bridge end-to-end. The Ruby gem previously droppedVisitorHandlefrom codegen via[crates.ruby] exclude_types, so the documentedHtmlToMarkdown.convert(html, MyVisitor.new)example silently ignored the visitor and emitted default markdown. Removed the exclusion — alef’s magnus backend already implements the full visitor trait bridge, so the regen produces a workingRbHtmlVisitorBridgethat dispatches eachvisit_*callback viarespond_to?+funcalland translates Ruby return values (:continue/:skip/:preserve_html/{custom: "..."}) intoVisitResultvariants. Closes #388. -
bindings(elixir): expose
ConversionOptions.visitorand wire the rustler visitor bridge end-to-end. Same fix as Ruby: removedVisitorHandlefrom[crates.elixir] exclude_types. The alef rustler backend already ships the bridge — a system thread runs the conversion, sends{:visitor_callback, ref_id, callback_name, args_json}to the caller, and blocks onHtmlToMarkdown.Native.visitor_reply/2until the receive loop inHtmlToMarkdown.convert/2dispatches the user’s visitor map and replies. Visitor maps are keyed by callback name ("visit_link","visit_text", …) with one-arity function values. -
list: nested-list duplication in Markdown output and the document structure collector (PR #385, fixes kreuzberg#1004).
push_list_itemwas previously called withoutput[item_start_pos..], which included the rendered Markdown of nested<ul>/<ol>children. Inner items’ text was triple-counted: once as the item itself, once inside the parent item’s text, and once as free text from the walker. Atext_end_poscursor now advances only past non-list children. Affects anyul > li > ul(or arbitrarily nested) shape. -
alef.toml: dropped stale Ruby/Elixir
vendor_mode = "core-only"overrides — alef now defaults source-build languages tovendor_mode = "registry"(the migrated install-from-crates.io flow), and the sharedbuild-ruby-gem/build-elixir-hexactions enforce the same rewrite at workflow time. Overriding tocore-onlywould silently vendor the core crate intopackages/{ruby,elixir}/vendor/and leave the manifest pointing at the workspace path, which fails to resolve on consumer machines. -
crates/html-to-markdown-py/src/pyproject.toml: maturin
manifest-pathcorrected to../Cargo.toml(was../../crates/html-to-markdown-py/Cargo.toml, which resolved tocrates/crates/…from the pyproject’s own directory). Localuv sync --upgrade/task upgradenow completes; the publish workflow builds viamaturin buildwith explicit paths and was not affected. -
packages/swift/rust/src/lib.rs: regenerated against alef 0.19.7 with the swift-bridge inbound trait phantom fix. The previous alef emitted
Vec<Swift{Trait}BoxBox>in theextern "Rust"block (double-Boxsuffix), which swift-bridge-build rejected with “Type must be declared withtype >”. The inbound phantom is now omitted entirely —Swift{Trait}Boxis anextern "Swift" typewith no Rust-side struct backing it, and the Vec accessors are not consumed by the bindings we emit. -
.task/languages/kotlin_android.yml + alef.toml
[crates.update.kotlin_android]: thecom.github.ben-manes.versionsGradle plugin (current pin 0.52.0, latest 0.53.0) throwsjava.util.ConcurrentModificationExceptionunder Gradle 9.5.1 on every:dependencyUpdatesinvocation; the plugin has not shipped a release since 2024-11. Bothalef update --latestandtask kotlin_android:upgradepreviously failed the rest of the upgrade chain. Inert-echo the probe in both places until upstream lands a Gradle 9 fix or we swap tonl.littlerobots.version-catalog-update.
-
alef test-apps generatefirst-class subcommand, bundled intoalef allas a discrete pipeline stage so registry-mode test_apps regenerate alongside the local-mode e2e suite. Per-stage stale-file sweep prevents either output dir (e2e/vstest_apps/) from deleting the other’s files (the bug that wiped half oftest_apps/whenalef allran in the previous topology). -
Two new test_app channels emitted by the new subcommand:
test_apps/homebrew/(Brewfile +run_tests.sh+ffi_smoke.c— exercises both Homebrew formulae viabrew bundle installand a pkg-config-linked C smoke test) andtest_apps/php_ext/(PIE-installed native ext driver —pie install xberg-io/html-to-markdown-extthenextension_loaded+ convert smoke). -
alef-generated
build.gradle.ktsfortest_apps/kotlin_android/— registry mode consumes the publisheddev.kreuzberg:html-to-markdown-android:3.5.xMaven artifact instead of the local workspace AAR. -
Taskfile:
e2e:smoke:homebrewande2e:smoke:php_extentries wired into thee2e:smoke:allaggregator. -
.gitattributes: markstest_apps/**aslinguist-generated=true(via the alef-scaffold change that now reads[e2e.registry].output).
-
docs/snippets/ruby/visitor/basic_visitor.mdrewritten against the magnus-real API: visitor is a positional second argument toHtmlToMarkdown.convert, callbacks dispatch viarespond_to?+funcall, return values are:continue/:skip/:preserve_html/{ custom: "..." }, thectxargument is a Hash with:node_type/:tag_name/:depth/ etc. The previous snippet used avisitor: MyVisitor.newkwarg form andresult[:content]accessor that don’t match the gem’s actual surface. -
docs/snippets/elixir/visitor/basic_visitor.mdrewritten against the rustler-real API: visitor is a%{"visit_*" => fn args -> ... end}map under the:visitorkey of the options Hash; the bridge sends{:visitor_callback, ref_id, callback_name, args_json}messages and blocks onvisitor_reply/2. The previous snippet used an aspirationaluse HtmlToMarkdown.Visitorbehaviour form that alef does not generate.
-
publish-swiftjob removed from.github/workflows/publish.yaml. Swift Package Index has no central registry — packages are consumed directly from git tags, so the tag push IS the publish event. The previouscheck-spi-swift+publish-swiftjobs only pinged SPI for fast re-indexing (SPI auto-discovers tags within ~1h without a ping). Cuts runner cost; SPI catches up automatically. -
publish-zigretained — appends the tarball URL + SHA-256 to the GitHub Release notes viaupdate-release-notes: true, which downstream consumers copy directly intobuild.zig.zon. -
actions/publish-hexbumped to v1.6.9: generatesCargo.lockfor everynative/**/Cargo.tomlbeforemix hex.publish. The lockfile is gitignored, so the publish action’s fresh checkout had nothing to publish; this mirrors thebuild-elixir-hexfix from earlier in the v3.5.0 cycle. -
task upgradeworks end-to-end again after the Python and Kotlin Android fixes above.
[3.5.0] - 2026-05-25
Section titled “[3.5.0] - 2026-05-25”-
bindings: regenerated with alef 0.19.6. Node optional-dep package names now carry the
@kreuzberg/scope (@kreuzberg/html-to-markdown-node-<target>) sorequireOptionalDependency()resolves the published per-platform packages instead of an unscoped name that does not exist on npm.test_apps/restructured by alef to the new layout (per-language runners undertest_apps/<lang>/{ffi,htm_test,run_tests}for C; legacy in-tree test files removed). Additional alef-emitter, swift-bridge, FFI param handling, and ahash-scaffold fixes carried through from the 0.19.x line. -
ci(publish-hex): bump to xberg-io/actions@v1.6.9 — generate
Cargo.lockbeforemix hex.publish.publish-hex@v1runs a freshactions/checkout, so the gitignoredpackages/elixir/native/html_to_markdown_nif/Cargo.lockis absent andmix hex.publishfails withMissing files: native/html_to_markdown_nif/Cargo.lock. v1.6.9 mirrors thebuild-elixir-hexfix and runscargo generate-lockfilefor everynative/**/Cargo.tomlbefore publishing. (v1floating tag retagged.) -
ci(publish): rename Go FFI tarballs to use
-go-infix instead of-ffi-. alef 0.19.6’s Go packager (alef publish package --lang go) emitted{crate}-ffi-v{version}-{platform}.tar.gz, colliding with the C FFI packager’s prefix.check-registryasset-prefix probes andverify-release-assetspattern lists could not distinguish Go from C FFI tarballs, soverify-release-assetsfailed on the missinghtml-to-markdown-rs-go-*.tar.gzpattern. Workaround: rename-ffi-v→-go-vimmediately afteralef publish package --lang goin the workflow. The alef-side fix is already on alef main; the workaround can be dropped once the local alef pin moves to a release that ships it. -
ci(actions/build-elixir-hex): generate
Cargo.lockunconditionally afterrewrite-native-deps. The fallbackcargo generate-lockfileonly ran on dry-run, but the lockfile is gitignored — real-release runs hitMissing files: native/html_to_markdown_nif/Cargo.lockatmix hex.build. Now runs in both modes. (xberg-io/actions v1.6.6, floatingv1retagged.) -
ci(publish): split Dart pub.dev publishing into a workflow_dispatch flow so OIDC trusted publishing succeeds. pub.dev’s OIDC verifier rejects tokens minted by
releaseevents (Authentication failed!) and only acceptspush/workflow_dispatch. The publish workflow now assembles the Dart package as an artifact under therelease-triggered run, then dispatches a separatepublish-pubdev.yamlworkflow (workflow_dispatch) that downloads the artifact and runsdart-lang/publish-pub@v1. The dispatched job inherits a token GitHub’s OIDC provider mints withevent_name == workflow_dispatch, which pub.dev’s audit accepts. -
ci(actions/homebrew-build-bottles): suppress
brew configSIGPIPE underpipefailon arm64 Linux runners. The arm64 runner’s/usr/bin/lddwrites more output thanhead -20consumes; underset -o pipefailthe broken-pipe propagated out of the early diagnostic block and aborted the script before any bottle work. Temporarily disablespipefailfor the diagnostic stanza only — strict mode is restored before the build phase. (xberg-io/actions v1.6.7, floatingv1retagged.) -
bindings: regenerated with alef 0.19.5. Picks up the Kotlin Android trait-bridge codegen fixes that caused the v3.5.0-rc.2
:compileReleaseKotlinfailure (Unresolved reference 'HtmlToMarkdownRsBridge'):bridge_objfilename is now used for trait-bridge codegen so the file matches the object name; trait-bridge emission is skipped when the bridge function is excluded viakotlin_android.exclude_functions. Also picks up the alef 0.19.5 cumulative sweep: WASM emitter JSON-deserializes structured sub-config fields; swift-bridge restores JSON deserialization step in pre-call AHashMap binding; alef-emitter removes stray>after.collect::<Vec<Vec<String>>>(); PHP/Ruby/Elixir/Swift/Dart bridges emit pre-callAHashMapbinding +ahash = "0.8"scaffold dep forCow<'static, str>key map params; WASM wraps sanitizedVec<Vec<String>>fields withserde_wasm_bindgen::to_value(); WASM deduplicates input DTO struct generation across functions sharing the same config type; FFI preservesAHashMap<Cow<'static, str>, _>param types across the wrapper boundary; setup-defaults-ruby appends--add-checksumsto defaultbundle install; scaffold-ffi injects workspace version into every internal workspace dependency socargo publishaccepts the FFI crate. -
ci(publish): replace inline Homebrew formula updater with
xberg-io/actions/publish-homebrew-source-formulas@v1. Thepublish-homebrew-formulajob previously ran a 184-linescripts/publish/update-homebrew-formula.shBash heredoc that wrotehtml-to-markdown.rb+libhtml-to-markdown.rbfrom scratch (h2m is a dual-formula tap; the shared single-formulapublish-homebrew@v1doesn’t apply). The new shared action does the same job from per-formula.rb.tmpltemplates + ascripts/publish/homebrew.jsonmanifest, downloading release assets viagh release downloadand substituting their SHA256s into${cli_*_sha}/${ffi_*_sha}placeholders. The script is deleted; the formula generation rules now live in version-controlled Ruby templates rather than a bash heredoc. The job’s gate now also passes ondry_run == 'true'(wasis_tag == 'true'only) so dry-run pipelines exercise the bottle pipeline downstream — the new action substitutes a zero-SHA placeholder for missing assets on dry-run. -
list: Fix content duplication in Markdown output and the document structure collector when list items contain nested
ulorolchildren.item.rspreviously captured the full rendered output of an<li>— including the rendered nested-list Markdown — as the item’s text. Atext_end_poscursor now advances only past non-list children, so the structure collector records only the item’s own text and the Markdown output for outer and mid items is not repeated. Affected: anyul > li > ulorol > li > ol(arbitrarily nested) HTML structure. (#385) -
ci(publish): skip
publish-hexandhomebrew-bottleson dry-run. Both jobs need real GitHub Release assets (generate-elixir-checksumsdownloads NIF tarballs;brew install --build-bottledownloads CLI/FFI source tarballs), butupload-release-assets@v1only logs on dry-run — the release doesn’t exist. Both jobs failed every dry-run after surviving every other stage. Gated theirif:ondry_run != 'true'; real-release runs continue to exercise them. -
ci(publish): replace inline Elixir Hex packaging with
xberg-io/actions/build-elixir-hex@v1. Theelixir-packagejob in.github/workflows/publish.yamlpreviously ranmix deps.get+mix hex.buildinline with no path-dep rewrite and noCargo.lockgeneration.Cargo.lockis gitignored, so on a fresh CI checkoutmix hex.buildfailed at thefileslist check withMissing files: native/html_to_markdown_nif/Cargo.lock— the proximate blocker on v3.5.0-rc.2 dry-run after the Go FFI fix landed. The new shared action wrapsrewrite-native-deps@v1(default-on, dry-run-guarded) and falls back tocargo generate-lockfileon dry-run so the lockfile exists formix hex.buildeven when the rewrite is skipped. Hex source-package builds now match the python-sdist / ruby-gem pattern (rewrite baked into the shared action; cannot be omitted). -
ci(publish): build the Go FFI matrix on dry-runs too.
.github/workflows/publish.yamljobgo-ffi-librariesgated only onrelease_go == 'true'and the registry existence check, so onworkflow_dispatchdry-runs it skipped entirely. Its downstream siblingupload-go-releasealready gates onis_tag || dry_runbut waits forgo-ffi-libraries.result == 'success', so no Go assets were ever uploaded to the dry-run release, and the terminalverify-assetsgate failed with✗ pattern NOT matched: html-to-markdown-rs-go-*.tar.gzon every recent retry (the immediate blocker on v3.5.0-rc.1 publish). Added the(is_tag == 'true' || dry_run == 'true')clause togo-ffi-libraries’sif:, mirroring thekotlin-android-nativespattern (precedent: commite00a56e1“ci(publish): build Kotlin Android natives on dry_run too”). -
ci(e2e): pin
erlef/setup-beam@v1.24.0(was@v1.24floating minor) to avoid silent action upgrades during the rc cycle. -
ci(e2e/ruby): drop the explicit
python3 scripts/ci/ruby/vendor-core-crate.pystep from both ruby build jobs in.github/workflows/ci-e2e.yaml(committed earlier today asf23e6d458); the dead script itself is removed inf440af4fa. The sharedxberg-io/actions/build-ruby-gem@v1action now invokesrewrite-native-deps@v1internally and vendors the core crate intopackages/ruby/vendor/html-to-markdown/(no-rssuffix). Running the local script first wrotepackages/ruby/vendor/Cargo.tomlwithmembers = ["html-to-markdown-rs"]and copied the crate intovendor/html-to-markdown-rs/; the subsequent action then created a siblingvendor/html-to-markdown/outside that members list, and cargo refused to build it witherror inheriting 'lints' from workspace root manifest's 'workspace.lints'/'workspace.lints' was not defined. The action is now the single source of truth for ruby vendoring.
Changed
Section titled “Changed”-
ci(publish): pin
actions/checkout@v5on thehomebrew-bottlesmatrix job. v6 hits an includeIf credential regression on themacos-15-intelrunner that this matrix includes (same workaround liter-llm applies on its Homebrew matrix). Other jobs stay on@v6. -
release: cut v3.5.0. Promoted from v3.5.0-rc.3 after the dry-run/republish cycle (publish run 26393110006) reached fully green (31 success / 0 failed / 56 skipped). Aligned every workspace manifest on
3.5.0viaalef sync-versions --set 3.5.0+ fullalef generateregen. -
release: cut v3.5.0-rc.1 release candidate. Aligned every workspace manifest (Cargo + npm + PyPI + Maven + Composer + Gemfile + Hex + pub.dev + Zig + R + Cocoa + nuget + Cargo.lock entries) on
3.5.0-rc.1viaalef sync-versions --set. Refreshed every per-language dependency tree to its current upstream pin (alef update --latest) and re-generated all bindings, READMEs, docs reference pages, and e2e suites against alef 0.18.1 so the regen artefacts on disk match the version pin baked into every binding manifest. -
bindings: regenerated with alef 0.19.2. Picks up the cumulative v0.19.1 → v0.19.2 sweep: Swift codegen handles
serde_rename_all = "lowercase"/"UPPERCASE"in unit enums and tagged Codable shapes (was silently emitting wrong casing); Swift now emits a custom Codable for serde-untagged data enums (the auto-derive previously used the wrong shape, surfacing as 19 runtime e2e failures); tagged-data enums route throughJSONDecoderandVec<Codable-enum>closure signatures are fixed. The trait-bridge codegen pipeline is rewired across all 14 language backends (rust/python/ts/node/wasm/ruby/php/go/c#/r/zig/elixir/dart/swift/java/kotlin-android) — super-trait lifecycle methods (name/version/initialize/shutdown) are driven by the IR instead of hardcoded literals, and canonical bridge-name helpers in Swift/Kotlin de-duplicate the prior near-misses. JNI no longer special-cases trait impl-name extraction; Kotlin Android accepts abridge_class_nameparameter so non-kreuzberg consumers (e.g. liter-llm) can opt out of the hardcodedKreuzbergBridge/kreuzberg::references. Java’spackage_diroutput_paths override now excludes setup/test/lint runs from.../src/main/java/so they execute frompackages/java/as expected; JNI 0.22 compatibility was tightened (RuntimeMethodSignatureparsing, borrow semantics, unsafe-block scoping,JStringlifetime binding). Inherited from the v0.19.1 fix landed earlier today: magnus (Ruby) and rustler (Elixir)NodeContent::MetadataBlockreverse conversion now reconstructsVec<(String, String)>from the sanitizedVec<Vec<String>>binding shape (the prior code didn’t type-check and broke every Ruby build). -
bindings: regenerated with alef 0.18.1. Picks up the v0.18.0 → v0.18.1 sweep: Java Builder
#[serde(default)]non-optional fields use boxed nullable types so omitted JSON keys survive Jackson round-trip; PHP enum-variant accessor paths skipped before field validation; Python pyproject TOML arrays normalised to canonicalpyproject-fmtshape; the Windows e2e runner no longer clobbers the inheritedPath(case-insensitive env-key collision onstd::process::Command::env); Ruby scaffold emits aSteepfilethat ignoreslib/<gem>/native.rbso Steep stops tripping on the Sorbet sigs the magnus backend deliberately emits;alef readmemarkdown normalisation matchesrumdl-fmtMD012 (one blank max) so cold-regen READMEs no longer diverge from thealef alloutput; rubocop double-quote /%w[...]defaults emitted directly; Kotlin Android.gitkeepis empty (matchesend-of-file-fixer); WASM optionalised non-Duration fields preserve coreDefaultvia the if-let wrapper;Box<str>round-trips through bindingStringvia the newCoreWrapper::Boxclassifier; barestrresolves toTypeRef::Stringinstead of falling throughsanitize_type_ref; Zig test preamble installsSIG_IGNonSIGABRTso C++ destructorabort()s don’t break the test-runner IPC; Java sealed-interface tagged-enum field defaults emitnew EnumName.Variant()(records can’t be statically referenced); PHP binding structs with custom coreDefaultsuppress the auto#[derive(Default)]and emit a delegatingimpl Default; doc-test import paths use the published crate namealef(post workspace-collapse); kotlin-android snapshot updated for vanniktech 0.36 (SourcesJar.Sources(), no-argpublishToMavenCentral()); Java e2e resolves per-fixture mock URLs from system properties; JavaVec<T>/Map<K,V>marshaling usesMAPPER.writerFor(constructCollectionType(...))to preserve@JsonTypeInfodiscriminators; PyO3_to_rust_*_configaliases serde-renamed keys + final pyo3 calls use serde-renamed param names. -
bindings: regenerated with alef 0.18.0. Workspace collapse + 0.17.36 → 0.18.0 cumulative codegen sweep. Notable fixes consumed in this regen: PyO3
replace_constructor_with_serde_renameskips the trait-bridge options field (visitor) fromsorted_fieldsto avoid emitting it twice onhas_defaulttypes with cfg-gated bridge fields (h2m all-featuresConversionOptions::newpreviously failedrustc E0415: identifier 'visitor' is bound more than once); PHP e2e codegen sources the JSON key rename strategy from the language-effectiveserde_rename_all(camelCase by default) rather than the Rust core type’s (which isNonefor h2m’sConversionOptions) sofrom_jsonreads the keys correctly into the binding struct’s#[serde(rename_all = "camelCase")]— fixes 32 / 7-error PHP e2e failures;alef allrepopulatescurrent_gen_pathsfrom the e2e cache manifest on a cache hit so the orphan-cleanup pass does not delete every previously-generated e2e file (157 deletes observed on first warm run);[crates.{node,wasm}.crate_dir]per-language override lets h2m point alef at its actualcrates/html-to-markdown-{node,wasm}Rust crate dirs (without the-rsinfix that the default formula assumed). -
package READMEs: regenerated with cold
alef readme. The 0.18.0 regen sweep usedalef allwhose hot-cache README pass omits the blank lines between consecutive##section headers that the standalonealef readmepass emits cold. CI’sValidate READMEsstep (coldalef readme) caught the divergence; this commits the cold output. Same cosmetic alef bug as the priordcd072a58patch; tracked separately upstream. -
bindings: regenerated with alef 0.17.36. Picks up the v0.17.18→v0.17.36 cumulative codegen sweep, including: Rustler
from_jsonNIF shims gated on types with NIF wrappers (fixes Elixir NIF compile errors forConversionOptionsUpdate,PreprocessingOptionsUpdate,NodeContext); Kotlin Android= PreprocessingOptions()synthesized defaults for non-nullable nested struct fields with RustDefault(fixes JacksonMissingKotlinParameterExceptionon partial-options JSON, 98 → 0 failures); Kotlin sealed-class@field:JsonSerialize(as = …)/contentAsannotations; Kotlin@JsonIgnoreProperties(ignoreUnknown = true)+ nullable default for#[serde(flatten)]fields; JNIcrate_suffix = "-jni"build fix; Zig test_apps build.zig references bindingmodule_name(not registrypkg_name) forroot_source_file; Zig local-path dependencies in registry-mode test_apps; Dart pubspec single-caret version constraint; wasmpackage.jsonfilenames use underscores (html_to_markdown_wasm.js); plus inherited 0.17.18-0.17.23 fixes (Swift visitorcase continue, C# enum converters + nullable options, PHPwith_visitorwither for trait-bridge fields, PyO3 streaming wrapper type identity, Go unresolved-Named fallback, Java sealed-interface display helpers, Ruby array literals, Dart positional-vs-named heuristic, R.alef_format_valuewrapping, WASM camelCase input DTOs). Includes C visitor test suite (208 → 262 tests). The v0.17.25→v0.17.27 increment additionally brings: Rustler opaque-type NIF resource wrappers and enum-variant-field type collection (fixescannot find typeerrors for types reachable only through enum variants); Kotlin AndroidLongMethodadded to the generated@file:Suppresslist, trait-interface emission skipped (noIDocumentExtractor/IRendererredeclaration),Vec<u8>return types mapped toByteArray, and integer-like float literals normalized; Javanull(not"") builder default forPathfields and streaming adapters skipped; PHP#[php(name = …)]on facade static methods; Swiftunwrap_orinstead ofunwrap_or_elsein serde fallbacks (clippy-D warnings); WASM stops emitting the broken*Inputconfig DTO; and the extractor skips underscore-prefixedpub fns (test-only helpers no longer leak into bindings or docs). The v0.17.28→v0.17.32 increment additionally brings: C FFI struct-field getters for nested struct andOption<T>fields now return a cloned, boxed value instead ofnull(htm_html_metadata_document,htm_conversion_result_document,htm_conversion_result_metadata); the Nodeindex.d.ts/index.jsare emitted by NAPI-RS at build time and no longer carry an alef generated-header; plus Swift visitorVisitResult, Java sealed-interface, and Ruby scaffold corrections. The v0.17.32→v0.17.34 increment additionally brings: aLICENSEfile synced into every per-language package directory (pub.dev, RubyGems, and other registries require one); per-language READMEs that render only their own binding section instead of leaking every language’s bullet; the PHP e2e harness no longer re-execs PHP with-n(PHPUnit keeps shared modules); the Ruby scaffold’s.rubocop.ymlexcludes generatedlib/**/*.rb; PHP streaming facade methods + snake_case adapter names; PyO3 keyword-escaping and signature defaults in serde-rename constructors; Javaclear_fn(int, String)error constructor; and C/Zig/C# e2e codegen compile fixes. The v0.17.34→v0.17.35 increment additionally brings: PyO3 trait-bridge constructor signatures and dict-coercion exclude the synthetic bridge field (e.g.visitor) — fixes spuriousvisitor=...kwargs on generated Python constructors and stale dict round-trips; magnus RBS type aliases use lowercase identifiers (json_value) — invalid uppercaseJsonValuewas breakingsteep check; ext-php-rs getters forOption<NonOpaqueNamed>explicitly.map(Into::into)instead of relying on the blanketIntoZvalimpl that did not unwrap the inner type; Dart e2e fixture codegen emits a defaultConversionOptions()instance for absent required-positional options; Javaoptions_typeinherits from sibling language overrides; CLI--versionsyncspackages/zig/build.zig.zon; kotlin-android publishing switched to the vanniktech maven-publish plugin for Maven Central Portal. The v0.17.35→v0.17.36 increment additionally brings: the/* serde(default) */placeholder marker (used as arequired-suppression flag for C#) is filtered before emission inalef-codegenandalef-backend-java— generated Java now containsList.of()/false/0instead of/* serde(default) */, and generated magnus Rust uses the type’s actual zero value (vec![]/false); kotlin-android.editorconfigdisables ktlint’strailing-comma-on-call-siteand-on-declaration-siterules (ktfmt strips them, ktlint demands them — the two were fighting onbuild.gradle.ktsafter the vanniktech migration); brew unsupported-callunsupported_inconfiguration; csharp variant-struct payload type fix; alef-e2e elixir Rustler NifTaggedEnum tuple emission for tagged-enum array args; publish workflow ordersalef-scaffoldahead of backends.
-
Taskfile: add
dart:e2e,swift:e2e,zig:e2e,kotlin_android:e2econvenience tasks so all 16 language bindings have a uniform per-language local e2e entry point matching the existingc:e2e/python:e2e/node:e2e/ etc. pattern. Each delegates toalef test --e2e --lang <name>; the previously-existinge2e:test:<lang>tasks (which run against the published-packagetest_apps/registry tree) stay alongside. Verified all 16 suites pass locally: c (262), csharp (262), dart (262), elixir, go, java, kotlin_android, node (262), php (262 — after the alef PHP rename fix), python (262), r (639), ruby (262), rust, swift (262), wasm (263), zig. -
e2e/php: skip visitor fixtures in
fixtures/edge-cases/visitor_errors.json— five visitor fixtures (visitor_custom_element_with_nesting,visitor_unknown_tag_preservation,visitor_deeply_nested_skip,visitor_element_start_skip_entire_subtree,visitor_element_end_modification) now carry"skip": { "languages": ["php"] }matching the rest of the visitor fixtures already infixtures/visitor/*.json. The PHPext-php-rsbinding does not yet support callable visitor handles (noVisitorHandle::from_php_object()method), so these tests were emitting unbuildable test code. Generated PHP tests dropped from 262 to 208; suite is fully green. -
e2e/kotlin_android: build the JNI crate in the e2e before-hook — the Kotlin/Android host-JVM e2e loads
libhtm_jni(thehtml-to-markdown-rs-jnicrate’s[lib] name = "htm_jni"cdylib), not the C FFI dylib. The[crates.test.kotlin_android]before-hook inalef.tomlwas buildinghtml-to-markdown-ffi, so all 208 tests failed withUnsatisfiedLinkError: no htm_jni in java.library.path. It now buildshtml-to-markdown-rs-jni; the suite is fully green. -
e2e/node: invoke the napi build via
pnpm run buildin the e2e before-hook — the[crates.test.node]before-hook ran a barenapi build, which is not onPATHin CI (the@napi-rs/clibinary lives innode_modules/.bin). The before-hook now runspnpm install && pnpm run buildso napi resolves from the crate’s ownpackage.jsonbuildscript, fixingsh: 1: napi: not found.
-
core: fix panic slicing output at a non-UTF-8 char boundary —
paragraph.rscapturedcontent_start_pos = output.len()before appending a separator; a subsequentoutput.pop()in the whitespace-normalisation path ofhandle_spancould shift the effective boundary one byte back, landing it mid-codepoint (e.g. inside U+25A0 ■). The structure-collector sliceoutput[content_start_pos..]then panicked. Fixed by clamping withfloor_char_boundarybefore slicing; the same clamp is now applied at the two analogous sites infigure.rs. Triggered byinclude_document_structure = truewith a<pre>preceding block and a<span>whose first character is multibyte. (#380) -
core: avoid stack overflow on documents with many unclosed list items — preprocessing now applies the HTML5 implicit-close rule for
<li>,<dt>, and<dd>before thetlparser sees the document, so a 15k-item changelog with bare<li>tags (e.g.https://curl.se/changes.html) is parsed as siblings instead of a 448-deep chain. Eliminates a process abort (fatal runtime error: stack overflow) that bypassedResult::Errandstd::panic::catch_unwind. (#379)
Documentation
Section titled “Documentation”- install: Windows MSVC linker / PATH troubleshooting —
docs/installation.mdand the per-package Python READMEs now documentpip install --only-binary=:all: html-to-markdownand how to keep MSVC’slink.exeahead of GNU/CygwinlinkonPATHwhen sdist fallback is unavoidable. (#378) - wasm:
NodeContent::MetadataBlock { entries }round-trips correctly as[[k,v],...]— theentries: Vec<(String, String)>field is now stored asJsValuein the wasm binding struct and serialized/deserialized viaserde_wasm_bindgeninstead ofVec<String>, preserving the nested-array wire format that serde produces for tuple vecs.
-
compact_tablesoption — setcompact_tables: trueonConversionOptionsto emit GFM tables with no column padding. Cells are flushed to content width and separator rows use exactly---per column, producing token-efficient output for RAG / LLM pipelines. Defaultfalse; existing output is unchanged. -
Kotlin Android binding —
dev.kreuzberg:html-to-markdown-androidon Maven Central. Standalone Android library (AAR) with bundledlibhtml_to_markdown_ffi.soforarm64-v8aandx86_64ABIs; minSdk 21, compileSdk 35. JVM Kotlin users continue to consume the existing Java package (dev.kreuzberg:html-to-markdown) directly — Kotlin/JVM treats Java classes as native and Panama FFM is unavailable on Android, which is why Android needs its own package. -
Swift binding —
HtmlToMarkdownSwift Package on Swift Package Index. SPM-only (no CocoaPods); macOS 13+, iOS 16+; powered byswift-bridge. -
Dart binding —
h2mon pub.dev. Built withflutter_rust_bridge2.12; supports Flutter Android/iOS targets plus server Dart on Linux/macOS/Windows. Package nameh2mbecausehtml_to_markdownandhtml-to-markdownare taken on pub.dev. -
Zig binding — published via GitHub Releases (
build.zig.zon+ tarball SHA-256 in release notes). Requires Zig 0.16+; links the existinghtml_to_markdown_ffiC library — no separate Rust bridge crate. -
ffi:
htm_visitor_handle_from_callbacksexports a vtable-style visitor-handle constructor for zig and other C consumers. Wraps aHtmVisitorCallbacksstruct into theVisitorHandleshape expected byhtm_conversion_options_builder_visitor.
[3.4.1] - 2026-05-13
Section titled “[3.4.1] - 2026-05-13”Changed
Section titled “Changed”- core:
ConversionOptionsis nowSend + Sync.VisitorHandleswitched fromRc<RefCell<dyn HtmlVisitor>>toArc<Mutex<dyn HtmlVisitor + Send>>, allowingConversionOptionsto be stashed in axum/tokio/rmcpSend-bound contexts. Bindings update bridge constructors to useArc::new(Mutex::new(...))and addunsafe impl Send + Syncwhere required (NAPI, WASM, Magnus, FFI visitor structs).
- R:
convert(html, options)no longer rejectsConversionOptions$default()andConversionOptions$builder()$build(). The extendr wrappers return anExternalPtr<ConversionOptions>, not an R named list, sodecode_optionsraised"options must be a named list"for every call that used the convenience constructors.decode_optionsnow extracts the wrapper directly before falling back to list decoding. - C#:
[DllImport("html_to_markdown_ffi")]resolves underProjectReferenceconsumption. P/Invoke only searches the assembly directory and standardDYLD/LD_LIBRARYpaths, not NuGet’sruntimes/<RID>/native/layout, so e2e and any other ProjectReference-based test project failed withDllNotFoundException.packages/csharp/HtmlToMarkdown/HtmlToMarkdown.csprojnow copies the host-RID native library flat alongside the assembly via<Content Include="runtimes/$(NETCoreSdkRuntimeIdentifier)/native/*" Link=… />. NuGet consumers continue to pick it up from the existing<None Include="runtimes/**" />pack. - C#: CS0579 “Duplicate
AssemblyTitleAttribute” eliminated. The SDK auto-synthesisesAssemblyInfofrom<PropertyGroup>, which collided with checked-inProperties/AssemblyInfo.csfiles. All three csproj files now set<GenerateAssemblyInfo>false</GenerateAssemblyInfo>and ship explicitProperties/AssemblyInfo.cs. - PHP: visitor
Customoutputs preserve case for custom element callbacks (#2b54751a). - PHP: PIE binary discovery on macOS/Linux (#2b54751a).
- Docs:
extract_metadatadescription clarified to note it only gates the metadata pass — table extraction intoresult.tablesstill runs unconditionally (#4ba2187a).
[3.4.0] - 2026-05-09
Section titled “[3.4.0] - 2026-05-09”- Homebrew distribution for
html-to-markdown(CLI) andlibhtml-to-markdown(FFI library + headers + pkg-config + CMake configs). Pre-built tarballs for macOS arm64/x86_64 and Linux arm64/x86_64; install withbrew install xberg-io/tap/html-to-markdown. - WASM bundles for all four wasm-pack targets (
web,bundler,nodejs,deno) under@kreuzberg/html-to-markdown-wasm. - C# NuGet package
KreuzbergDev.HtmlToMarkdownwith native runtimes for linux-x64, linux-arm64, osx-x64, osx-arm64, win-x64, win-arm64. - Java Maven Central package
dev.kreuzberg:html-to-markdownbundling native libraries for the same six platforms viaMETA-INF/native/<rid>/. - Elixir Hex package with
rustler_precompiledNIFs for Linux + macOS (NIF 2.16/2.17 × 3 platforms); released artifacts download at first run. - PHP PIE pre-built archives for PHP 8.2/8.3/8.4/8.5 × 6 platforms —
pie install xberg-io/html-to-markdown-rsno longer requires building from source. - CLI panic guard — conversion failures inside the CLI now surface as actionable errors via
panic::catch_unwindinstead of partial output + Rust backtrace. HtmlVisitorparity across all bindings — Python, Node/TypeScript, Ruby, PHP, Go, Java, C#, Elixir, R, and WASM all expose the visitor interface withvisit_element_start/visit_text/visit_element_endandVisitResult::{Continue, Skip, Custom}semantics matching the Rust core.- Polyglot codegen via alef — bindings, e2e tests, and READMEs for all 11 target languages are generated from a single
alef.toml+ Rust source of truth, eliminating drift across the polyglot surface.
- #348 —
OutputFormat::PlainignoredHtmlVisitorcallbacks. The plain-text walker (crates/html-to-markdown/src/converter/plain_text.rs) ran the markdown pipeline first, then discarded its output and re-traversed the DOM via a visitor-lesswalk_plain, soVisitResult::Custom/Skipreturned fromvisit_element_end/visit_textwas silently dropped forPlain. Threaded aWalkStatecarrying the visitor through the plain walker so element/text hooks fire and their results are honoured. - #347 —
<img src>URLs not escaped, breaking CommonMark round-trip.crates/html-to-markdown/src/converter/handlers/image.rsemittedsrcraw, while<a href>already wrapped spaces/parens in angle brackets. Image renderer now uses the same three-branch escaping as links: empty →<>, contains space/newline →<URL>, unbalanced parens →\(/\)escaping. - #336 — large MS Word HTML truncated when
<td><p class='MsoNormal'>…</td>appears as the leading cell. Thetlparser absorbs subsequent<td>and document content into the unclosed<p>, nesting the rest of the DOM inside the first table cell. Extendedhas_inline_block_misnestinconverter/preprocessing_helpers.rswith ahas_p_ancestorcheck that detectstd/tr/thunder<p>(structurally impossible in valid HTML) and triggers the existing html5ever repair path. - Split closing tags
</tagname\n>corrupted DOM and dropped content. JSX-style HTML (closing-tag>on the next line) caused thetlparser to leave elements unclosed, which silently absorbed siblings and dropped entire sections — affecting #127 (MW841 product headings missing from multilingual page), #143 (word-wrap merging nested link list items), and #121 (SPA menu nesting). Newnormalize_split_closing_tagspreprocessing pass collapses such patterns to</tagname>before parsing, wired into all four preprocessing branches inconverter/main.rs. - Tables now emit padded, aligned columns. Each cell is padded to the widest cell in its column; the separator row uses
max(3, col_width)dashes per column.*and_are escaped in table cells regardless ofescape_misc. Fixes the gh-140 fixture parity and produces CommonMark-conformant tables out of the box. - #339 — bogus HTML comment endings dropped following content. The
astral-tlparser silently discarded every byte after<!-- /// --->or any--[-]+>comment terminator. Newnormalize_bogus_comment_endingspreprocessing pass rewrites such sequences to-->before parsing; wired into the html5ever-repair and inline-block-misnest fallback paths too. - #340 — npm pre-release versions clobbered the
latestdist-tag. Pre-release versions (matching-(rc|beta|alpha|pre|dev)) now publish under thenextdist-tag, sonpm install @kreuzberg/html-to-markdown-nodeno longer pulls a 3.4.0-rc over a stable 3.3.x. - #337 —
from html_to_markdown import HeadingStyleraisedTypeError. The package now re-exports the native PyO3 enums directly from_html_to_markdownand adds uppercase aliases (HeadingStyle.ATX,CodeBlockStyle.BACKTICKS) so both naming conventions satisfyConversionOptions(heading_style=…). - #334 — Ruby
HtmlToMarkdown.convert(html, options)raisedTypeErroron every call with options. The wrapper passed aConversionOptionsobject to the FFI, but the generated Rust function expectsOption<String>JSON. Wrapper now serialises the options hash to JSON before crossing the FFI boundary. - #332 —
default-features = falseRust build broken. Bare#[serde(...)]and#[derive(Serialize, Deserialize)]on core types insrc/types/{document,tables,result,warnings}.rsandsrc/options/conversion.rsare now feature-gated behind#[cfg_attr(feature = "serde", ...)]. CI now runs acargo check --no-default-featuresmatrix to prevent regressions. - #331 — visitor
element_start/element_endevents mispaired for hyphenated/namespaced custom tags. Therepair_with_html5everfallback re-parsed under HTML5 semantics, which discard XML-style self-closing on unknown elements. The repair path now pre-expands XML self-closing tags on non-void elements to explicit open+close pairs before the HTML5 parse. - PHP visitor marshaling — visitor callbacks now correctly marshal arguments and handle array return values;
setVisitor()method added toConversionOptions. - Elixir metadata serialization — metadata maps now serialize as JSON instead of Elixir debug format.
- WASM Vitest environment — WASM module loading now correctly handles Node.js module format in Vitest test environments.
- R e2e result wrapping —
result_is_r_listconfigured to suppressjsonlitedouble-wrapping of conversion results.
Changed
Section titled “Changed”- pnpm v11 — migrated from pnpm v10 to v11;
pnpm-workspace.yamldeclaresonlyBuiltDependencies: [esbuild]andignoredBuiltDependencies: [wasm-pack]for the new opt-in build script policy. - Cross-language dependency bumps —
org.jetbrains:annotations26.0.0 → 26.1.0, plus updates across all language toolchains viatask upgrade.
[3.3.3] - 2026-04-23
Section titled “[3.3.3] - 2026-04-23”- Python enum KeyError (#324) —
ConversionOptions()with default enums no longer crashes; PyO3 enum fields are passed directly instead of brokenstr()+ map lookup. - Ruby Magnus binding — fixed 65 compilation errors:
funcallAPI, visitor bridge args,Vecconversion, optional flattening, sanitized field serde round-trip. - Elixir
.formatter.exs— 120-char line length, generated code now passesmix format --check-formatted. - Unused deps — removed
serde_jsonfrom Node and WASM binding crates. - Checkstyle — excluded
test_apps/from pre-commit checkstyle hook.
[3.3.2] - 2026-04-23
Section titled “[3.3.2] - 2026-04-23”- Elixir visitor bridge — implemented async thread-based visitor protocol using
rustler::thread::spawn+OwnedEnv::send_and_clear+mpscchannels, replacing the impossible synchronousenv.call()approach. - Elixir NIF rustler 0.37 — replaced removed
SavedTerm,is_nil(),Pid::spawn_monitor,.encode()APIs with 0.37-compatible equivalents. - Elixir type conversions — fixed double-optional wrapping (
map(Some)) and ambiguousFromimpl in generated_frommethods. - Java checkstyle — added
maven-checkstyle-pluginto pom.xml pointing to projectcheckstyle.xml(120-char limit), somvn checkstyle:checkuses our config instead of default Sun checks. - Ruby Rakefile — explicit
Bundler::GemHelper.install_tasks name:for Bundler 4 compatibility.
[3.3.1] - 2026-04-23
Section titled “[3.3.1] - 2026-04-23”- Java checkstyle — switched to 120-char line limit, added Spotless auto-formatting with Eclipse JDT formatter, added
finalparams and javadoc to all generated code. - Elixir
listtype collision —NodeContent::Listvariant no longer redefines Elixir’s built-inlist/0type (now emitslist_variant). - Elixir NIF missing
serde— addedserdewith derive feature as direct dependency to the NIF crate. - C#
VisitResult.Continue— default visitor methods now usenew VisitResult.Continue()instead of non-invocableVisitResult.Continue(). - Node
convertexport — restored the missing#[napi] pub fn convertfunction dropped during binding regeneration. - Ruby CI — updated Bundler from 2.7.2 to 4.0.3 to match
Gemfile.lock.
[3.3.0] - 2026-04-23
Section titled “[3.3.0] - 2026-04-23”exclude_selectorsoption — CSS selector-based element exclusion. Unlikestrip_tags(which removes the wrapper but keeps children), excluded elements and all descendants are dropped entirely. Supports any CSS selector:.class,#id,[attribute], compound selectors. Works in both markdown and plain text output modes.- CLI flags —
--preserve-tags,--skip-images,--max-depthfor full ConversionOptions parity. - Visitor pattern for all bindings (#314, #313) — restored visitor support across Python, TypeScript, Ruby, PHP, Go, Java, C#, Elixir, R, WASM, and C FFI.
- R visitor support — added visitor callbacks for the R binding.
- E2E test fixtures — 78 new fixtures for 100% ConversionOptions field coverage (35/35 fields). Added fixtures for
exclude_selectors,ConversionResult.tables, andConversionResult.warnings. - Ruby RBS type stubs — auto-generated via alef from the Rust IR, including
VERSIONconstant. Gemspec now includessig/**/*. - Alef pre-commit hook —
alef-verifyhook added to.pre-commit-config.yamlto check generated code freshness. CI installs alef v0.5.3 binary.
<h1>inside<header>not exported (#321) — top-level<header>elements were unconditionally dropped during preprocessing; now only<header>with navigation hints (e.g.class="site-header",role="navigation") is removed.PreprocessingPresetnot wired into preprocessing logic — thepresetfield onPreprocessingOptionswas defined but never checked. Now Minimal/Standard/Aggressive presets have distinct behavior.remove_formsflag was dead code —<form>elements are now dropped whenremove_forms: trueand preset is Standard or Aggressive.- Aggressive preset — now drops navigation-hinted elements of any tag type,
<noscript>elements, and noise-hinted elements (cookie banners, ad containers). - Python
NodeContenttype — binding wrapper now implementsDefault,Serialize,Deserializevia forwarding to core type, fixing compilation whenDocumentNode(which containsNodeContent) derives these traits. - Python
dict[str, Any]for data enums —NodeContent,AnnotationKind,VisitResultnow use TypedDicts withLiteraldiscriminators instead of untyped dicts. - Python
__init__.pyexports — all public types now exported from the package. ImageMetadata.dimensions— tuple type(u32, u32)correctly maps toVec<u32>/number[]/[]uint32in all bindings via serde round-trip conversion.- FFI doc comments — multi-line doc strings on VTable struct fields now properly prefixed with
///on every line (was breakingcargo fmt). - FFI optional parameters — visitor methods with optional string params (
title,id,lang) correctly generateOption<&str>instead of&str. - FFI duplicate imports — removed duplicate
use std::ffi::...in trait bridge output. - Go
htm_convertstub — FFI function was always returning “Not implemented”; now delegates tocore::convert(html, options, None). - Go lint errors — fixed CGO preamble types, removed invalid
?syntax, fixed parameter syntax,NodeTypemapping, variable shadowing, gofmt indentation. - Java FFI broken on all platforms (#315) — native libraries were bundled under wrong JAR path.
- Java/C# visitor type conflicts — fixed by skipping gen_bindings types when visitor bridge is active.
- Ruby
convert()TypeError (#319) — options type mismatch and wrong return type. - PHP binding panics —
from_updateandfrommethods now emit safe return values instead ofpanic!(). - R
ConversionOptionsBuilder— fixed placeholder panics in opaque type delegation. - CLI
autolinksdefault — replaced--autolinkswith--no-autolinksso defaults match library. - CLI dead metadata flags — removed flags that were parsed but never wired through.
- Python 3.14 wheels (#322) — enabled
abi3-py310stable ABI for PyO3 crate, so a single wheel works on Python 3.10 through 3.14+ without per-version builds.
Changed
Section titled “Changed”- Public API surface restricted — internal Rust modules changed from
pubtopub(crate). API docs now document only the public API (down from 233 to 66 items). - alef.toml simplified — switched from 20-item include whitelist to minimal 3-type + 1-function include. Transitive dependencies expand automatically.
- Generated code lint-clean — all generated bindings pass their respective language linters (
cargo fmt,cargo clippy,mypy,ruff,gofmt,dotnet format,mix format,steep check,rubocop,phpstan,biome). - Dead code removed — deleted unused dispatch functions, duplicate text utilities, and unused safety module from core crate.
- CI consolidated (#317) — 13 workflows merged into single
ci.yaml.
[3.2.6] - 2026-04-20
Section titled “[3.2.6] - 2026-04-20”- Python type mismatch —
convert()return type annotation now uses the publicConversionResultinstead of_rust.ConversionResult, fixing Pylance type errors when annotating with the re-exported type (#310). - Maturin build failure — removed
readme = "README.md"from binding crate Cargo.toml files (ffi, node, php, py, wasm) since no README exists for these internal crates (#309).
Changed
Section titled “Changed”- PHPUnit — bumped from
^12.5to^13.1across root, e2e, and test_apps. - Pre-commit shfmt hook — fixed rev from non-existent
v3.14.2-1tov3.9.0-1. - Pre-commit ai-rulez hook — updated from
v3.14.0tov3.14.2.
[3.2.5] - 2026-04-18
Section titled “[3.2.5] - 2026-04-18”- Silent truncation on large HTML inputs (#277) — fixed preprocessing pipeline gap where
strip_script_and_style_tagswas not called afterrepair_with_html5ever, causing documents with custom elements and scripts containing literal<script>strings to be silently truncated. Also fixedpreprocess_htmlfallback to skip only the problematic tag instead of consuming the rest of the document. - WASM plain-object options (#303) — WASM package now exports a wrapper
convert()function that accepts plain JavaScript objects for options, eliminating the need to constructWasmConversionOptionsclass instances. The raw WASM classes remain available for advanced use.
max_depthoption — newmax_depthfield onConversionOptionsto limit DOM traversal depth, preventing stack overflow on deeply nested or malicious HTML. Default isNone(unlimited). When set, subtrees beyond the limit are silently truncated.
Removed
Section titled “Removed”- Phantom type references (
MetadataResult,HeadingInfo,LinkInfo,ImageInfo) from alef configuration that did not correspond to any Rust types.
[3.2.4] - 2026-04-17
Section titled “[3.2.4] - 2026-04-17”- Elixir Hex package — fixed precompiled NIF pipeline: correct build output paths, removed Rust source from Hex files list, removed Windows target (no build job), simplified build script for
rustler_precompiled. - C# NuGet package — republished with
htm_conversion_options_from_jsonand related FFI functions.
[3.2.3] - 2026-04-17
Section titled “[3.2.3] - 2026-04-17”- Java/C#/Go FFI functions —
htm_conversion_options_from_json,htm_preprocessing_options_from_json, and relatedto_jsonfunctions now generated. Fixed alef IR extraction to detect serde derives inside#[cfg_attr(...)]attributes. - Node.js native binding loader — regenerated
index.jswith correct NAPI platform-aware loader (was referencing oldhtml-to-markdown-rs.nodebinary name). - Go module path — fixed from non-existent
github.com/xberg-io/html-to-markdown-goto monorepo pathgithub.com/xberg-io/html-to-markdown/packages/go/v3. - Elixir precompiled NIFs — switched from
Rustler(compile-from-source) toRustlerPrecompiledwith CI jobs for building and uploading platform-specific NIF binaries to GitHub releases.
Removed
Section titled “Removed”- Stale hand-written test files superseded by alef-generated e2e tests (comprehensive_test, feature_test, smoke_test duplicates across Go, Python, Ruby, PHP, Node, WASM, Elixir, R).
- Empty placeholder crate directories (
html-to-markdown-rs-ffi,html-to-markdown-rs-wasm). - Duplicate Ruby extension directory (
html-to-markdown_rbwith wrong naming).
[3.2.2] - 2026-04-16
Section titled “[3.2.2] - 2026-04-16”- Ruby binding compilation — fixed serde derive errors and Default trait conflicts by conditionally deriving serde traits only when all field types support it, and generating Default derives for kwargs constructors.
- Ruby deprecated Magnus API — replaced
magnus::exception::type_error()/runtime_error()withRuby::exception_type_error()/Ruby::exception_runtime_error()(Magnus 0.7+ API). - PHP e2e tests — fixed missing class import causing “Class not found” fatal error.
- Cargo.toml metadata — added missing
readme,keywords,categories,descriptionfields to binding crate Cargo.toml files. - C# dotnet format — auto-formatted generated C# bindings.
[3.2.1] - 2026-04-16
Section titled “[3.2.1] - 2026-04-16”-
Node.js Docker/cross-platform installs (#273) — platform-specific native packages (
@kreuzberg/html-to-markdown-node-linux-x64-gnu, etc.) are now correctly published withoptionalDependenciesvia NAPI prepublish, resolving cross-platform lockfile issues. -
Homebrew formula (#304) — formula updated with correct source tarball SHA and bottle configuration.
-
Ruby gem build failure — fixed
NodeContent::MetadataBlocktype mismatch (Vec<(String, String)>→String) in binding-to-core conversion by deserializing sanitized fields from JSON. -
Maven Central publish — aligned
pom.xmlwith kreuzberg: GPG plugin in main build section, developer email, correctgroupId(dev.kreuzberg),pluginManagementwith pinned plugin versions. -
All binding compilation failures — fixed private module path (
convert_api) in generated bindings, missingFromimpls for function return types, glob import conflicts in PyO3/FFI backends, and cbindgen compatibility (removedconst extern fn, updated to cbindgen 0.29). -
Elixir NIF compilation — added
compilers: [:rustler] ++ Mix.compilers()tomix.exsso Rustler compiles the NIF duringmix compile. -
FFI
from_json/to_jsonfunctions —htm_conversion_options_from_json,htm_conversion_result_to_json, etc. now generated for all serde-compatible types, fixing Java (Panama FFM) and Go (cgo) bindings. -
PHP e2e tests — fixed function call generation to use correct
HtmlToMarkdownRs::convert()pattern. -
Python
pyproject.toml— correctedmodule-nameandpython-packagesto matchhtml-to-markdownpip package name. -
docs/llms.txtmetadata defaults corrected fromfalsetotrueforextract_metadata,extract_document,extract_headers,extract_links,extract_images,extract_structured_data(#276). -
WASM type prefix restored (#303) —
WasmConversionOptions(notJsConversionOptions) via configurabletype_prefixin alef. No breaking change for WASM users.
Known Issues
Section titled “Known Issues”- Python silent output cap (#277) —
convert()silently truncates output at ~439 KB on certain large HTML inputs. Under investigation.
[3.2.0] - 2026-04-14
Section titled “[3.2.0] - 2026-04-14”Breaking Changes
Section titled “Breaking Changes”Core Defaults Changed
Section titled “Core Defaults Changed”code_block_styledefault changed fromIndentedtoBackticks— code blocks now use triple-backtick fences by default instead of 4-space indentation.bulletsdefault changed from"-"to"-*+"— nested unordered lists now cycle through-,*,+at successive nesting levels.preprocessing.enableddefault changed fromfalsetotrue— HTML preprocessing (navigation removal, form stripping) is now on by default.- Rust serde field names changed from
camelCasetosnake_case— affects JSON serialization/deserialization of the Rust coreConversionOptionsstruct (heading_styleinstead ofheadingStyle). Language bindings are not affected — each binding uses its language-native naming convention (camelCase for JS/TS/Java/C#, snake_case for Python/Ruby/Elixir/R).
Package Renames
Section titled “Package Renames”- TypeScript/Node.js: npm package renamed from
@kreuzberg/html-to-markdownto@kreuzberg/html-to-markdown-node. - PHP: Namespace changed from
HtmlToMarkdown\toHtml\To\Markdown\Rs\. Main class renamed fromHtmlToMarkdowntoHtmlToMarkdownRs. - Java: Maven coordinates remain
dev.kreuzberg:html-to-markdown. Internal package namespace changed todev.kreuzberg.htmltomarkdown. - C FFI: Function prefix changed from
html_to_markdown_tohtm_(e.g.,htm_convert,htm_last_error_code). Header moved toinclude/html_to_markdown.h. - C#: Main class renamed from
HtmlToMarkdownConvertertoHtmlToMarkdownRs.
Python Exception Hierarchy
Section titled “Python Exception Hierarchy”- Old exceptions (
HtmlToMarkdownError,EmptyHtmlError,InvalidParserError, etc.) replaced with new hierarchy:ConversionError,ParseError,SanitizationError,ConfigError,IoError,InvalidInputError,PanicError,OtherError.
ConversionOptionsBuilderis now public in the Rust API — useConversionOptions::builder()for ergonomic option construction.TableDataexported from crate root — no longer requires importing from submodules.- Per-language API reference documentation — generated
docs/reference/api-{lang}.mdpages for Python, TypeScript, Go, Java, C#, Ruby, PHP, Elixir, WASM, and C with full type mappings, signatures, and docstrings. - Alef codegen — all 12 language bindings (Rust, Python, TypeScript, Go, Java, C#, Ruby, PHP, Elixir, WASM, C, R) are now auto-generated from a single IR via alef, configured in
alef.toml. - E2E test suite — 130 fixture-driven tests across all 12 languages, generated from shared JSON fixtures in
fixtures/. AnnotationKindandNodeContentnow implementDefault.ConversionResultnow derivesSerialize/Deserialize.
Changed
Section titled “Changed”- Docs platform: Switched from MkDocs to Zensical (
zensical.tomlreplacesmkdocs.yaml). - READMEs: Now generated by
alef readmefrom minijinja templates with inline configuration inalef.toml. - Version sync:
task version:syncnow usesalefbinary for all operations (replacessync_versions.py). - CI workflows: Simplified to e2e-only testing per language — removed per-binding unit test jobs.
Removed
Section titled “Removed”crates/html-to-markdown-bindings-common— shared bindings helper crate replaced by alef codegen.tools/e2e-generator— replaced byalef e2e generate.tools/snippet-runner— snippet validation removed.scripts/generate_readme.py,scripts/sync_versions.py,scripts/readme_filters.py— replaced by alef.- Hand-written binding code — all per-language binding implementations replaced by alef-generated code.
- Preserve metadata when using AST visitors (#279).
deny_unknown_fieldsadded to serde option structs — invalid JSON fields now produce errors instead of being silently ignored.- Ruby e2e generator — fixed camelCase→snake_case field name conversion.
- WASM test options — added missing
link_stylefield.
[3.1.0] - 2026-04-01
Section titled “[3.1.0] - 2026-04-01”- Reference-style links: New
link_styleoption ("inline"default,"reference") renders links as[text][1]with numbered[1]: url "title"definitions appended at the end of the output. Supports URL+title deduplication, images (![alt][1]), and media elements (audio, video, iframe). Available across all bindings (Python, Node.js, WASM, PHP, CLI--link-style, FFI via JSON).
[3.0.2] - 2026-04-01
Section titled “[3.0.2] - 2026-04-01”- Structure collector in tables: Suppressed
StructureCollectorcalls for headings and lists inside table cells, preventing spurious document-structure nodes from table content. - Char boundary safety: Fixed potential panics from slicing at non-UTF-8-char boundaries in
generate_idhash truncation and list item text extraction. - Dead feature gates removed: Cleaned up unused
document-structurefeature gates that were no longer wired to any Cargo feature. - Structure collector coverage: Added missing
StructureCollectorcalls for lists, images, and code blocks so document structure captures all block-level elements.
[3.0.1] - 2026-03-31
Section titled “[3.0.1] - 2026-03-31”- WASM TypeScript types:
convert()now returns typedWasmConversionResultinstead ofany. AllWasmConversionTable,WasmGridCell,WasmTableGrid,WasmConversionWarning, andWasmInlineImageinterfaces are now emitted in generated.d.tsfiles. Added missing options fields (skipImages,outputFormat,includeDocumentStructure,extractImages,maxImageSize,captureSvg,inferDimensions). Fixes #265. - Python type stubs: Synced crate
.pyistub with package stub — added keyword-only (*) parameter markers andvisitorparameter toconvert(). - PHP type stubs: Expanded PHPStan stubs with full
ConversionResult,ConversionOptions, and all nested type shapes. Wired stubs intocomposer.jsonPHPStan config.
[3.0.0] - 2026-03-30
Section titled “[3.0.0] - 2026-03-30”- Single
convert()API: One entry point across all 12 language bindings returningConversionResultwith content, document, metadata, tables, images, and warnings. ConversionResulttype: Structured result withcontent(markdown/djot/plain),document(optionalDocumentStructure),metadata(HtmlMetadata),tables(grid-based),images(inline image data), andwarnings.DocumentStructure: Structured document tree with flat node array, index-based parent/child references, andTextAnnotationfor inline formatting.- Options support in all bindings: Go, Java, C# now accept options. All generators wire fixture options into e2e tests.
- GFM defaults: Code blocks default to backtick fences (was indented). ATX headings remain default.
- E2E contract validation: Generators produce tests validating ConversionResult structure (metadata, tables, warnings) across all 12 languages.
- New options:
includeDocumentStructure,extractImages,maxImageSize,captureSvg,inferDimensions,outputFormat(markdown/djot/plain). <q>element: Wraps content in quotation marks.<figure>/<figcaption>elements: Routed to semantic handler with caption separation.hiddenattribute: Elements withhiddenstripped before parsing.
Changed
Section titled “Changed”convert()returnsConversionResultinstead ofStringin all bindings (Go, Java, C#, Node, Python, PHP, Ruby, Elixir, R, WASM, C FFI).ExtendedMetadatarenamed toHtmlMetadataacross all crates and bindings.- Go
Convert()returns*ConversionResultwithContent,Metadata,Tables,Images,Warningsfields. Accepts optional JSON options via variadic parameter. - Table data uses grid-based schema (
TableGridwithGridCell) instead of flatcells [][]string. serde(deny_unknown_fields)onMetadataConfig,MetadataConfigUpdate,InlineImageConfigUpdate.- Go 1.26, golangci-lint@latest.
Removed
Section titled “Removed”- All
convert_with_*functions:convert_with_metadata,convert_with_inline_images,convert_with_visitor(standalone),convert_with_tables,convert_with_async_visitorremoved from public API. Singleconvert()replaces all. - Async visitor: Feature removed entirely (
async-visitorCargo feature,AsyncHtmlVisitortrait, async bridge/dispatch code). - Profiling: All profiling infrastructure removed (8 binding crates, CI workflow, C tests,
start_profiling/stop_profilingAPIs). - Benchmarks: All benchmark scripts and harness removed.
- hOCR support: Entire
hocrmodule deleted. Thehocr_spatial_tablesoption removed. - Python v1 compatibility:
convert_to_markdown()andmarkdownify()removed. - Redundant binding tests: Tests covered by e2e generators removed from Python, Ruby, Elixir, R.
[2.30.0] - 2026-03-27
Section titled “[2.30.0] - 2026-03-27”Deprecated
Section titled “Deprecated”- hOCR support: The
hocr_spatial_tablesoption and all hOCR-related APIs are deprecated and will be removed in v3. All hOCR functionality continues to work but emits deprecation warnings. This is the final v2 release.
- PHP PHPStan CI errors: Removed redundant
@varannotations andis_array()runtime checks inExtensionBridge.phpthat PHPStan flagged as always-true due to stub-defined return types. Removed redundantarray_values()calls inConversionOptions.phpon properties already typed aslist<string>. Updated PHPStan baseline count for callable invocations.
[2.29.0] - 2026-03-22
Section titled “[2.29.0] - 2026-03-22”fullfeature group: Added afullfeature to core crate and all binding crates (PHP, Python, Node, WASM, FFI, Elixir, bindings-common) that enables all available features. All bindings now default tofull.- Dublin Core metadata extraction:
DC.*andDCTERMS.*meta tags now map to dedicatedDocumentMetadatafields (title, description, author, keywords). Other DC/DCTERMS fields stored inmeta_tagswithdc_/dcterms_prefix. - Extended keyword variants: Keywords now extracted from
news_keywords,citation_keywords,DC.subject,DC.keywords,DCTERMS.subject,subject,topic,category, andclassificationmeta tags. cargo-sortpre-commit hook: Added for consistent Cargo.toml key ordering.checkmakepre-commit hook: Added for Makefile linting.typescript-typecheckpre-commit hook: Added TypeScript type checking viatsc --noEmit.typechecknpm script: Added topackages/typescript/package.json.
- Case-insensitive meta tag matching (#251): All meta tag name matching is now case-insensitive per the HTML spec.
<meta name="Keywords">and<meta name="DC.keywords">are now correctly captured. - PHP
convertWithTables()not found (#250): Thevisitorfeature was not enabled by default in the PHP binding crate, causinghtml_to_markdown_convert_with_tablesto be missing from the extension. - PHP binding defaults: PHP crate now defaults to
["full"](was["metadata"]), enabling visitor support. - Python binding defaults: Python crate now defaults to
["full"](was[]), enabling metadata, visitor, async-visitor, and inline-images. - PHPStan 2.x compatibility: Fixed 40+ PHPStan errors from the 1.x→2.x upgrade (type narrowing, property access on mixed, redundant assertions). Added
--memory-limit=512Mto prevent OOM. - Makefile
testtarget: Added missing.PHONY: testtarget to FFI test Makefile.
Changed
Section titled “Changed”- Pre-commit config aligned with kreuzberg: Added
cargo-sort,checkmake,typescript-typecheck. Updatedtaplo-formatto excludeCargo.toml. ExcludedMakefile.fragfrom checkmake. - Cargo.toml formatting: All workspace Cargo.toml files sorted via
cargo-sort. - pyproject.toml formatting: All pyproject.toml files formatted via
pyproject-fmt.
[2.28.6] - 2026-03-20
Section titled “[2.28.6] - 2026-03-20”Changed
Section titled “Changed”- Ruby gem vendoring: Replaced bash+embedded-Python vendoring script with a standalone Python vendoring script adapted from kreuzberg, using
vendor/directory instead ofrust-vendor/for core crate vendoring. - Ruby gem build: Added
build-native-gem.rbfor platform-specific pre-compiled gem builds, following kreuzberg patterns. - Pre-commit hooks: Switched Ruby hooks (rubocop, rbs-validate, steep-check) from inline bash commands to task-based delegation matching kreuzberg.
- Dependabot config: Expanded from GitHub Actions only to full multi-ecosystem coverage (Cargo, pip, npm, bundler, composer, gomod, maven, nuget, mix).
- Task update commands: Aligned all language update tasks with kreuzberg’s comprehensive approach (outdated checks, aggressive updates).
- C# update: Switched from slow
dotnet list --outdatedPython script todotnet-outdated-toolfor faster dependency updates.
- CI Validate shfmt failure: Fixed
packages/r/configure.wintab indentation to match shfmt 2-space requirement. - Java linting: Added PMD plugin (3.28.0), JaCoCo coverage (0.8.14), and pinned checkstyle runtime (13.3.0). Bumped maven-compiler-plugin to 3.15.0, maven-surefire-plugin to 3.5.5, spotless to 3.4.0, central-publishing to 0.10.0.
Updated
Section titled “Updated”- GitHub Actions: Bumped
go-task/setup-taskfrom v1 to v2,nick-fields/retryfrom v3 to v4. - Dependencies: Updated all language dependencies via
task update.
[2.28.5] - 2026-03-19
Section titled “[2.28.5] - 2026-03-19”- Table colspan parsing (#233): Fixed column count calculation to accurately use colspan values instead of incrementing by 1, and refined layout table heuristics to exempt simple data tables with colspans while correctly catching layout tables.
- Ruby version.rb formatting: Fixed missing space around
=operator inversion.rbthat caused Rubocop lint failures in CI. - CI tooling alignment: Aligned tooling and documentation with kreuzberg standards, fixing CI failures.
[2.28.4] - 2026-03-13
Section titled “[2.28.4] - 2026-03-13”- Panic with cid image followed by italic paragraph (#222): Confirmed fix for panic (“byte index 53 is out of bounds of ``”) when converting HTML containing
cid:image paragraphs followed by italicized text. This was resolved in v2.28.0 via the block_content_start bounds fix (#216, #217) and multi-byte UTF-8 character boundary fix (#218). - Ruby gem installation on macOS (#219): Confirmed fix for
Cargo.lockmissing from published gem causingmagnusdependency load failure. Resolved in v2.28.3 with native platform gem builds.
[2.28.3] - 2026-03-10
Section titled “[2.28.3] - 2026-03-10”- Java visitor FFI struct return type: Fixed
IllegalArgumentException: Wrong method handle typewhen using visitors in Java. The Panama FFI callback descriptors incorrectly usedJAVA_LONG(8 bytes) as the return type instead of the actualHtmlToMarkdownVisitResultC struct (24 bytes: enum + 2 pointers). All 14 callback descriptors now use a properStructLayoutmatching the C ABI. - Homebrew bottle tarball structure: Fixed bottle tarballs missing the required
html-to-markdown/{version}/prefix directory. Homebrew expects this prefix for proper cellar installation. - Ruby gem publishing: Added native platform gem builds (
rake native gem) alongside source gems so precompiled extensions are available for Linux, macOS, and Windows.
[2.28.2] - 2026-03-08
Section titled “[2.28.2] - 2026-03-08”- Publish workflow republish flag: Fixed republish mode skipping all publish jobs because
INPUT_REFresolved to a branch name instead of the tag ref. - Definition list fixture: Aligned real-world test fixture for
<dl>/<dt>/<dd>with actual converter output (plain text, no Pandoc-style:prefix).
[2.28.1] - 2026-03-06
Section titled “[2.28.1] - 2026-03-06”- Panic with multi-byte UTF-8 and visitor (#218): Fixed a panic (“byte index N is not a char boundary”) when converting HTML containing multi-byte UTF-8 characters (Cyrillic, CJK, emoji, etc.) with tabs between block elements and any visitor. The stale byte position captured before whitespace trimming could land inside a multi-byte character when new content was appended.
- Java formatting: Fixed spotless formatting violations in
HtmlToMarkdown.java,TableData.java, andTableExtractionResult.java.
[2.28.0] - 2026-03-05
Section titled “[2.28.0] - 2026-03-05”- Table extraction API: New
convert_with_tablesfunction that extracts structured table data during HTML-to-Markdown conversion. ReturnsTableDatastructs containing cell contents asVec<Vec<String>>, rendered markdown output, and per-row header flags. Uses the visitor pattern internally with a built-inTableCollectorto capture table structure in a single pass. Available across all language bindings:- Rust:
convert_with_tables(html, options, metadata_config)returningConversionWithTables - Python:
convert_with_tables(html, options, preprocessing, metadata_config)returningTableExtractionResult - TypeScript/Node.js:
convertWithTables(html, options?, metadataConfig?)returningTableExtraction - Ruby:
HtmlToMarkdown.convert_with_tables(html, options, metadata_config)returning a Hash - PHP:
HtmlToMarkdown::convertWithTables($html, $options, $metadataConfig)returningTableExtractionResult - Go:
ConvertWithTables(html)returningTableExtractionResult - Java:
HtmlToMarkdown.convertWithTables(html)returningTableExtractionResult - C#:
HtmlToMarkdownConverter.ConvertWithTables(html)returningTableExtractionResult - Elixir:
HtmlToMarkdown.convert_with_tables(html, options, metadata_config)returning{:ok, content, tables, metadata} - R:
convert_with_tables(html, options, metadata_config)returning a list - C (FFI):
html_to_markdown_convert_with_tables(html, options_json, metadata_json)returning JSON - WASM:
convertWithTables(html, options?, metadataConfig?)returning a JS object
- Rust:
- Plain text fast path skipping visitor callbacks: When
OutputFormat::Plainwas used withconvert_with_tables, the plain text fast path returned before the visitor could extract table data, resulting in empty tables. The conversion pipeline now runs the full visitor walk before returning plain text content.
[2.27.3] - 2026-03-05
Section titled “[2.27.3] - 2026-03-05”- Panic on block_content_start out of bounds: Fixed a crash (
byte index N is out of bounds) in text node processing when inline handlers (e.g.<strong>,<em>) collected children into a fresh buffer while inheriting a parent paragraph context. Theblock_content_startindex pointed into the wrong buffer, causing a panic on certain HTML structures — notably<details>containing<p>with inline formatting. (Issues #216, #217)
[2.27.2] - 2026-03-02
Section titled “[2.27.2] - 2026-03-02”- Plain text list items missing markers:
<ul>and<ol>list items inOutputFormat::Plainwere output without any bullet or number prefix. Now emits-for unordered lists and sequentialN.for ordered lists, respecting thestartattribute on<ol>.
[2.27.1] - 2026-03-01
Section titled “[2.27.1] - 2026-03-01”- Colon introduced into definition list text:
<dd>elements inside<dl>were incorrectly prefixed with:(Pandoc definition list syntax), introducing spurious colons into converted text. Standard Markdown and GFM do not support definition list syntax, so<dd>content is now output as plain blocks. (Issue #214, thanks @smoyerx) - Go test app go.sum out of sync: Updated
tests/test_apps/go/go.sumto match the v2.27.0 module version, fixing the CI Go lint job.
[2.27.0] - 2026-03-01
Section titled “[2.27.0] - 2026-03-01”- Plain text output format: New
OutputFormat::Plainoption that strips all markup and returns only visible text content. Setoutput_formatto"plain"(also accepts"plaintext"or"text"). This fast-path bypasses the full Markdown/Djot conversion pipeline — after DOM parsing, a lightweight text extractor walks the tree collecting only visible text with structural whitespace. Useful for search indexing, text extraction, and feeding content to LLMs.
[2.26.3] - 2026-02-28
Section titled “[2.26.3] - 2026-02-28”- Subscript/superscript content silently dropped: When
sub_symbolorsup_symbolwas empty (the default), text inside<sub>and<sup>tags was discarded entirely — e.g.H<sub>2</sub>OproducedHOinstead ofH2O. - Missing whitespace between newline-separated inline elements: Whitespace-only text nodes containing newlines between adjacent inline elements (e.g.
<a>…</a>\n<a>…</a>) were dropped, causing links and other inline markup to merge without a word boundary. Now collapses to a single space per HTML white-space normalization rules.
[2.26.2] - 2026-02-28
Section titled “[2.26.2] - 2026-02-28”- Inconsistent whitespace before inline elements across paragraphs: Fixed a stateful bug where
\nbefore<a>,<strong>,<em>, and other inline elements inside<p>tags was handled differently depending on the paragraph’s position in the document. The second and subsequent paragraphs would drop the space before inline elements, producingtext[link](url)instead oftext [link](url). (Issue #212, thanks @haroldparis)
[2.26.1] - 2026-02-27
Section titled “[2.26.1] - 2026-02-27”- YAML frontmatter in
convert_with_metadataoutput:convert_with_metadatano longer prepends YAML frontmatter to the markdown string. Since metadata is returned as a structuredExtendedMetadataobject, embedding it in the content string was redundant and polluted the output.
[2.26.0] - 2026-02-26
Section titled “[2.26.0] - 2026-02-26”- C FFI distribution infrastructure: Distribution-grade C FFI library with CMake/pkg-config integration, installation scripts, and packaging for system-level consumption.
- C FFI test coverage: Comprehensive C test suite covering conversion, metadata extraction, error handling, visitor pattern, profiling, and version queries.
- C documentation and examples: C API reference, getting-started snippets, and example programs for basic conversion, metadata extraction, and visitor pattern usage.
- R package r-universe build: Configure scripts now download the source archive from GitHub when the monorepo is unavailable, enabling r-universe and standalone source installs to vendor crates automatically.
[2.25.2] - 2026-02-25
Section titled “[2.25.2] - 2026-02-25”- Visitor panic with metadata extraction: Fixed an out-of-bounds slice panic when using visitors (e.g. image visitors returning
Custom) combined with metadata extraction on minified HTML. The issue occurred because parent element output offsets became stale after child visitor truncations. (PR #204, thanks @gmalette)
- R language bindings: Full-parity R bindings via extendr framework with support for
convert(),convert_with_options(),convert_with_options_handle(),convert_with_metadata(),convert_with_inline_images(),convert_with_visitor(), and profiling. Includesconversion_options()helper, testthat test suite, CI workflow, lintr/styler pre-commit hooks, and task automation. - R CRAN publishing infrastructure: Added
publish-cranjob to publish workflow,cran-comments.md, andNEWS.mdfor CRAN submission compliance.
Changed
Section titled “Changed”- Workspace restructuring: Moved Ruby native crate out of the root Cargo workspace into a standalone workspace (matching Elixir/R pattern), resolving
clang-syslink conflict withext-php-rs0.15.6. - Rust update task: Now updates dependencies in all separate workspaces (Ruby, Elixir, R) via
--manifest-pathentries. - Upgraded
wasmtimefrom 41 to 42. - Upgraded
ext-php-rsfrom 0.15.4 to 0.15.6. - Upgraded
pyo3from 0.28.1 to 0.28.2. - Upgraded
wasm-bindgenfrom 0.2.112 to 0.2.113. - Upgraded
rustlsfrom 0.23.36 to 0.23.37.
[2.25.1] - 2026-02-17
Section titled “[2.25.1] - 2026-02-17”- hOCR heading detection: Improved hierarchy logic to use font size (
x_fsize) and bbox height as a proxy when detecting headings. Large-font paragraphs now support longer text (up to 80 chars) and single-word headings. Added comprehensive test coverage for heading detection edge cases.
[2.25.0] - 2026-02-15
Section titled “[2.25.0] - 2026-02-15”- Bun runtime support: Official support for Bun 1.2+ via Node-API compatibility. The existing NAPI-RS bindings work in Bun without changes. Added Bun to CI test matrix and updated documentation to reflect runtime compatibility.
Changed
Section titled “Changed”- Vendored
markup5ever_rcdom: Brought themarkup5ever_rcdomcode (MIT/Apache-2.0) into the core crate as an internalrcdommodule. This removes the external dependency on the “+unofficial” crate, eliminates the unusedxml5evertransitive dependency, and removes the pinnedhtml5ever/markup5ever_rcdomversion constraints. SeeATTRIBUTIONS.mdfor license details. - Upgraded
html5everfrom 0.36.1 to 0.38.0 (now unpinned). - Upgraded
pyo3from 0.28.0 to 0.28.1.
[2.24.6] - 2026-02-14
Section titled “[2.24.6] - 2026-02-14”- Dependency update stability: Pinned compatible
html5ever/markup5ever_rcdomversions to prevent trait-mismatch breakages during workspace dependency updates. - Python bindings build: Added explicit
#[pyclass(from_py_object)]on Python config wrapper classes to avoid PyO3 deprecation failures under-D warnings. - Rust lint consistency: Aligned crate-level clippy configuration so
multiple_crate_versionsdoes not fail Node/WASM/FFI crate lint runs. - WASM dependency behavior: Updated hashing dependency configuration to avoid wasm randomness backend breakage after dependency updates.
- PHP PIE publish verification (macOS): Hardened PIE verification/build scripts for Darwin linker behavior and shell-safe package spec handling.
- CI reliability: Updated validation and Python CI tasks to reduce flakiness (PHP 8.4 setup in validate; avoid redundant Rust CLI release builds in Python test runs).
[2.24.5] - 2026-02-01
Section titled “[2.24.5] - 2026-02-01”- Subscript/superscript whitespace handling: Subscript and superscript tags now trim inner whitespace and place it outside delimiters, matching the behavior of bold, italic, and strikethrough (issue #202).
[2.24.4] - 2026-01-31
Section titled “[2.24.4] - 2026-01-31”Performance
Section titled “Performance”- Reduced allocations in hot conversion paths: Return
Cow<str>from escape to avoid allocating on no-op paths, replace.repeat()with direct push loops in heading/list/table/div/paragraph formatters, eliminatecollect::<Vec>::join()in text dedentation, and useAHashMapfor hOCR property maps.
- WASM builds: Updated
getrandombackend configuration from"js"to"wasm_js"for compatibility with getrandom 0.3.x. - Elixir/Ruby vendor scripts: Added missing
ahashworkspace dependency replacement for standalone builds.
[2.24.3] - 2026-01-31
Section titled “[2.24.3] - 2026-01-31”- Definition lists: Ensure
<dl>/<dt>/<dd>output is consistent regardless of HTML whitespace/minification, and properly indent multiline definition content (issue #200). - Link labels: Removed hard truncation of long link labels to avoid broken Markdown for large image links (issue #199).
[2.24.2] - 2026-01-29
Section titled “[2.24.2] - 2026-01-29”- Java packaging: Bundle native FFI libraries in published Maven JAR for all platforms (linux-x86_64, linux-aarch64, osx-aarch64, windows-x86_64). The Java package now works out-of-the-box when installed from Maven Central without requiring local FFI builds or manual java.library.path configuration. Native libraries are automatically extracted to a temp directory on first use with platform detection and fallback support.
[2.24.1] - 2026-01-27
Section titled “[2.24.1] - 2026-01-27”- UTF-16 recovery: Automatically recovers UTF-16 HTML (including data without BOM) that was read via lossy UTF-8 decoding, instead of rejecting it as binary data.
- URL sanitization: Hardened markdown-like URL sanitization to extract the real URL from
...[text](url)patterns inhref/srcattributes, preventing caller-side URL join/parsing errors. - Issue #190 coverage: Added regression fixtures and tests covering the reported real-world HTML inputs.
[2.24.0] - 2026-01-24
Section titled “[2.24.0] - 2026-01-24”Changed
Section titled “Changed”- Bindings API: Removed
_jsonconversion entrypoints across bindings; convert functions now pass full option payloads directly.
- Visitor docs: Corrected Python visitor documentation and examples (argument order + ctx access).
- Python visitor options:
convert_with_visitornow respects full conversion options payloads. - skip_images: Skip flag now suppresses SVG/graphic outputs in addition to
<img>. - Code block dedent: Handles Unicode whitespace without panicking on UTF-8 boundaries.
- Input validation: Tolerates small NUL byte artifacts and strips them before conversion.
[2.23.6] - 2026-01-21
Section titled “[2.23.6] - 2026-01-21”- pnpm lockfile synchronization: Fixed pnpm lockfile to include Node.js platform-specific optional dependency version updates (2.19.0-rc.1 → 2.23.6) that were applied during v2.23.5 version sync. This resolves the
ERR_PNPM_OUTDATED_LOCKFILEerrors that caused the v2.23.5 publish workflow to fail. - CI Java version: Updated CI Java workflow from Java 24 to Java 25 to match maven.compiler.release=25 configuration, ensuring CI and local builds use the same compiler version.
[2.23.5] - 2026-01-21
Section titled “[2.23.5] - 2026-01-21”- Maven Central publishing: Corrected group ID in Maven Central check script from legacy
io.github.goldzihertodev.kreuzberg, enabling successful Java package publishing. This resolves the issue where Java v2.23.4 failed to publish to Maven Central. - Go module publishing: Added automated Go module tag creation (
packages/go/v{version}) to publish workflow, ensuring Go packages are immediately available on Go proxy after release. - Go FFI version synchronization: Updated Go FFI default version constants from outdated versions (2.19.1/2.23.0) to 2.23.5 in both
ffi_loader.goandcmd/install/main.go, ensuring automatic downloads use the correct library version. - Node.js platform dependencies: Synchronized all platform-specific optional dependencies in
@kreuzberg/html-to-markdown-nodepackage.json from 2.19.0-rc.1 to match main package version, preventing dependency resolution issues. - Java benchmark packaging: Updated benchmark-pom.xml to use correct group ID (
dev.kreuzberg), version (2.23.5), and main class namespace (dev.kreuzberg.benchmark.Benchmark). Removed outdated generateddependency-reduced-pom.xml. - PHP package references: Updated all PHP package references from
goldziher/html-to-markdowntoxberg-io/html-to-markdownacross composer.json, PIE verification scripts, and smoke test actions to reflect current package organization. - Java smoke tests: Updated smoke-java GitHub action to use correct group ID (
dev.kreuzberg) and package namespace (dev.kreuzberg.htmltomarkdown.SmokeTest) for JAR installation and test execution. - Build tooling: Fixed Python script execution in task runner to use
uv run python3instead of system python3, ensuring consistent dependency resolution. Added PyYAML and Jinja2 to workspace dev dependencies. - Version sync automation: Enhanced version sync script to automatically update Node.js platform-specific optional dependencies alongside main package version, preventing manual version drift.
[2.23.4] - 2026-01-20
Section titled “[2.23.4] - 2026-01-20”- TypeScript wrapper publishing: Fixed TypeScript wrapper dependency resolution by installing dependencies directly from npm registry instead of from workspace after Node packages are published. This ensures
@kreuzberg/html-to-markdown-nodeis available from npm when building the TypeScript wrapper, eliminating the workspace resolution issues that caused previous build failures.
[2.23.3] - 2026-01-20
Section titled “[2.23.3] - 2026-01-20”- Go FFI packaging: Fixed missing
html_to_markdown.hheader file in Go FFI archive tarballs, which causedgo:generateinstallation to fail with “fatal error: ‘html_to_markdown.h’ file not found”. The header is now included in all platform archives (tar.gz and zip). - TypeScript wrapper publishing: Fixed pnpm lockfile frozen mode error during TypeScript wrapper dependency reinstallation by adding
--no-frozen-lockfileflag. The reinstall step after publishing Node packages now correctly updates workspace dependencies despite lockfile version mismatches.
[2.23.2] - 2026-01-20
Section titled “[2.23.2] - 2026-01-20”- TypeScript wrapper publishing: Fixed TypeScript wrapper build failures by moving the build and publish steps into the same
publish-nodejob. This eliminates npm CDN propagation delays that caused@kreuzberg/html-to-markdownto fail building because@kreuzberg/html-to-markdown-nodewasn’t available yet. Added workspace dependency reinstallation step to ensure pnpm correctly resolves the local package after publishing. - Go FFI library installation: Fixed critical bugs in the
go:generateinstall script that prevented automatic FFI library downloads:- Corrected artifact naming from
go-ffi-{platform}.tar.gztohtml-to-markdown-ffi-{version}-{platform}.tar.gz - Fixed platform mapping to match GitHub release artifacts (darwin-arm64, linux-x64, etc.)
- Added support for all library formats (.dylib for macOS, .so for Linux, .dll for Windows)
- Corrected artifact naming from
- Ruby native Cargo.toml: Fixed workspace dependency configuration to use
workspace = trueinstead of vendored path reference, preventing Cargo workspace resolution failures during builds. - CI workflows: Upgraded all CI workflows from Java 24 to Java 25 to match maven.compiler.release=25 configuration in pom.xml.
- Go linting: Resolved golangci-lint warnings by adding constants for OS names and library names, and converting if-else chains to switch statements.
Changed
Section titled “Changed”- Go README: Updated installation documentation to explain the
go:generateworkflow for automatic FFI library installation, including details about caching in~/.html-to-markdown/and alternative manual configuration.
[2.23.1] - 2026-01-19
Section titled “[2.23.1] - 2026-01-19”- Go module versioning: Created 14 missing Go module tags (packages/go/v2.16.1, v2.19.1-v2.19.8, v2.20.1, v2.21.1, v2.22.1-v2.22.5) to ensure all versions since v2.15.0 are available via Go proxy. Users can now
go getany version from v2.15.0 onwards. - TypeScript wrapper publishing: Added missing
publish-typescriptjob to publish workflow to properly publish@kreuzberg/html-to-markdownTypeScript wrapper package to npm alongside the native Node.js bindings (@kreuzberg/html-to-markdown-node). - Ruby gem vendoring: Fixed Ruby gem installation failures due to missing
.cargo-checksum.jsonfiles. Updated gemspec to include hidden files withFile::FNM_DOTMATCHflag, and improved vendoring script to generate checksums correctly with--lockedflag and proper cleanup. - Elixir package size: Reduced Hex package size from 134 MB to under 128 MB limit by aggressively removing unnecessary files from vendored dependencies (tests, docs, examples, static libraries, Windows-only crates on Unix builds).
- Go automatic FFI library installation: Implemented
go:generatepattern following Kreuzberg approach. Addedcmd/installpackage that automatically downloads platform-specific FFI libraries from GitHub releases and generates CGO flags. Users can now rungo generateafter installation instead of manually settingCGO_CFLAGSandCGO_LDFLAGSenvironment variables. FFI loader updated to check~/.html-to-markdown/for installed libraries.
[2.23.0] - 2026-01-18
Section titled “[2.23.0] - 2026-01-18”- Djot output format support: New
output_formatoption inConversionOptionsenables conversion to Djot lightweight markup language as an alternative to Markdown. Djot uses different syntax for emphasis (_text_), strong (*text*), strikethrough ({-text-}), inserted ({+text+}), highlighted ({=text=}), subscript (~text~), and superscript (^text^). - CLI: Added
--output-format/-fflag to specify output format (markdownordjot) - All language bindings: OutputFormat enum/option added to Python, TypeScript/Node.js, Ruby, PHP, Elixir, Go, Java, and C# bindings
- Documentation: Added Djot output format section to all package READMEs with syntax comparison table
- Python: Fixed async visitor bridge to properly await coroutines.
PyAsyncVisitorBridge::call_visitor_method_sync()now detects async methods via__await__attribute and usesPYTHON_TASK_LOCALSevent loop for proper async execution (issue #187) - Ruby: Fixed visitor parameter being ignored in
convert()wrapper method. Now correctly passes visitor to nativeconvert_with_visitorfunction when provided (issue #187)
Changed
Section titled “Changed”- Rust: Updated
async-visitorfeature to include requiredtokio“sync” feature forMutexsupport - Documentation: Added comprehensive visitor pattern support matrix showing which bindings support visitors
- Documentation: Documented WASM visitor pattern architectural limitation with four alternative approaches
[2.22.6] - 2026-01-16
Section titled “[2.22.6] - 2026-01-16”- Ruby gem dependency resolution: Ruby native extension now uses workspace version inheritance with vendoring approach. During gem build, the entire
html-to-markdowncrate is vendored with exact dependency versions intopackages/ruby/vendor/, making gems completely self-contained and eliminating crates.io dependency resolution during installation. Local development uses symlink to workspace crate for seamless workflow. - URL parsing robustness: Fixed IPv6 URL parsing error when processing malformed markdown-like URLs in HTML attributes (e.g.,
//[domain.com/path](http://domain.com/path)). Newsanitize_markdown_url()function detects and extracts actual URLs from markdown syntax that wasn’t properly converted in source HTML. Applied to both linkhrefand imagesrcattributes (fixes issue #186).
Changed
Section titled “Changed”- Ruby gem build process: Added
vendor-html-to-markdown.shscript that creates standalone vendor workspace before gem packaging. Ruby nativeCargo.tomlnow references vendored path for maximum reproducibility and build reliability.
[2.22.5] - 2026-01-16
Section titled “[2.22.5] - 2026-01-16”- Core: Added
#[serde(default)]attribute toConversionOptionsstruct to enable partial JSON deserialization. This allows deserializing JSON with only a subset of fields specified, using default values for missing fields. Fixes compatibility with language bindings (C#, Go, Java) that serialize partial configuration objects.
[2.22.4] - 2026-01-15
Section titled “[2.22.4] - 2026-01-15”- Core: Fixed
br_in_tablesoption not being respected correctly. HTML<br>tags in table cells now properly convert to markdown line breaks (spaces or backslash style based onnewline_styleoption), while block elements (divs, paragraphs) continue to generate literal<br>tags when needed for rowspan scenarios (issue #184) - WASM: Updated GitHub Pages demo to v2.22.4 with latest BR tag handling fixes
[2.22.3] - 2026-01-14
Section titled “[2.22.3] - 2026-01-14”- Python: Exposed
skip_imagesoption inConversionOptionsAPI, including type stub files (.pyi) for proper type checking support (issue #183) - Elixir: Added
skip_imagesoption toHtmlToMarkdown.Optionsmodule (was completely missing from Elixir binding) - Core: Fixed
<br>tags being output literally in table cells instead of converting to proper Markdown line breaks. Table cell paragraph and div separators now respectnewline_styleoption (issue #184)
[2.22.2] - 2026-01-13
Section titled “[2.22.2] - 2026-01-13”- Ruby gem standalone build - Fixed Ruby gem failing to build when installed from RubyGems. Removed
lints.workspace = true(which requires workspace context) and added inline lint configuration. This resolves issue #181. - Ruby gem version pinning - Changed
html-to-markdown-rsdependency from loose semver ("2.x.x") to exact pin ("=2.22.2") to prevent older gems from pulling incompatible newer crate versions. - Version sync script - Updated
sync_versions.pyto preserve exact version pin prefix (=) when syncing Ruby gem dependencies.
[2.22.1] - 2026-01-13
Section titled “[2.22.1] - 2026-01-13”- Java Maven Central publishing - Fixed Maven Central deployment by adding proper
publishprofile withcentral-publishing-maven-pluginconfiguration. The plugin is now correctly activated with-Ppublishflag and usesossrhserver credentials. - Java Spotless formatting - Updated google-java-format to 1.28.0 for Java 25 compatibility.
[2.22.0] - 2026-01-13
Section titled “[2.22.0] - 2026-01-13”- C FFI visitor implementation - Fixed
html_to_markdown_convert_with_visitorto properly use the visitor handle during conversion instead of discarding it. Previously the visitor was created but the plainconvert()function was called instead ofconvert_with_visitor(). - C# visitor callbacks - P/Invoke bindings now correctly invoke visitor callbacks during HTML-to-Markdown conversion (42/42 tests passing).
- Go visitor callbacks - Removed regex-based post-processing workaround; Go bindings now use real FFI visitor callbacks with proper struct field ordering.
- PHP visitor callbacks - Wired up
PhpVisitorBridgeto pass visitor to Rust core instead of ignoring the visitor parameter. - Java visitor callbacks - Added Panama FFI upcall stubs for all 38 visitor callbacks, enabling full visitor pattern support (95/95 tests passing).
- Java
VisitorCallbackFactory- New class that creates Panama FFI upcall stubs for visitor callbacks, enabling Java code to receive callbacks from the Rust core during conversion. - Java
HtmlToMarkdown.convertWithVisitor()- Public API method for converting HTML with a custom visitor implementation.
[2.21.1] - 2026-01-13
Section titled “[2.21.1] - 2026-01-13”- Serde serialization support for ConversionOptions - Added
SerializeandDeserializetraits toConversionOptions,PreprocessingOptions, and all related structs. Enables JSON serialization/deserialization with camelCase field naming and lowercase string enum representations.
Changed
Section titled “Changed”- Major refactor: Complete Phase 1 modular architecture - Restructured core converter into modular handler components:
- Extracted block element handlers (block-level HTML elements)
- Extracted inline element handlers (2,363 lines of focused code)
- Extracted table, list, and media handlers (2,528 lines)
- Extracted semantic and form handlers (1,532 lines)
- Improved code organization and maintainability across all language bindings
- Unified FFI bindings architecture - Consolidated common binding logic into shared crate, reducing duplication across Python, TypeScript, Ruby, PHP, Go, and Java bindings
- Added visitor callback code generation system - FFI now supports dynamic visitor callbacks for all language bindings (Python, Ruby, PHP, Elixir, etc.)
- Enhanced preprocessing system - Footer and nav element removal now integrated into preprocessing pipeline
- Improved custom element detection - Enhanced
has_custom_element_tagsto accurately detect only tag names with hyphens
Internal
Section titled “Internal”- Updated dependencies across all language bindings (Python, Ruby, PHP, JavaScript, Go, etc.)
- Refactored benchmark harness to modularize script adapters and reduce code duplication
- Refactored performance examples to extract and reuse shared utilities
- Improved sync_versions.py to handle all internal workspace dependency version pins
- Refactored README generation script to modularize template handling
- Improved clippy lint handling and CI coverage workflows
- Added documentation to Node.js binding example files
[2.21.0] - 2026-01-10
Section titled “[2.21.0] - 2026-01-10”skip_imagesconfiguration option - New option to skip all<img>elements during conversion, enabling greater control over image handling in the output.- Optional visitor parameter across all convert functions - Unified API for applying visitor patterns to all conversion modes:
convert(html, options, visitor)- Basic conversion with optional visitorconvert_with_inline_images(html, options, image_cfg, visitor)- Inline image extraction with optional visitorconvert_with_metadata(html, options, metadata_cfg, visitor)- Metadata extraction with optional visitor
- Visitor pattern integration with advanced features - Support for using visitor pattern simultaneously with inline images and metadata extraction, providing complete control over the conversion process.
- Comprehensive test coverage - Added tests validating
skip_imagesfunctionality and visitor pattern integration across all conversion functions and language bindings.
Changed
Section titled “Changed”- Visitor parameter unified across all APIs - The visitor parameter is now optional on all conversion functions, enabling consistent API design across basic, inline-images, and metadata extraction paths.
- Improved feature-gated architecture - Refined the feature gate handling for better flexibility when combining visitor patterns with other optional features.
Deprecated
Section titled “Deprecated”convert_with_visitor()function - Deprecated in favor of passing visitor as an optional parameter toconvert(). The dedicated function will be removed in a future major release. Useconvert(html, options, visitor)instead.
- Unused dependency warnings in npm packages - Resolved unused dependency warnings reported during builds of JavaScript/TypeScript packages.
- Feature gate handling for visitor combinations - Fixed issues with feature gate combinations when using visitor patterns alongside inline images and metadata extraction.
[2.20.1] - 2026-01-09
Section titled “[2.20.1] - 2026-01-09”Code Quality
Section titled “Code Quality”- Resolved all clippy warnings comprehensively: Fixed 207+ clippy pedantic/nursery warnings across entire workspace
- Removed blanket
#![allow(clippy::pedantic)]directives from all crate roots - Fixed trivial copy pass-by-ref issues in converter functions
- Added missing documentation sections (# Errors) to public APIs
- Fixed doc markdown formatting (added backticks to technical terms)
- Applied selective allows only for architecturally justified cases
- FFI/binding layers use targeted allows due to interop constraints
- Core library maintains strict clippy compliance
- Removed blanket
- Updated workspace lint configuration: Changed pedantic lints from deny to warn to allow module-level selective overrides
- Dependency modernization: Migrated from
once_cell::sync::Lazyto stdlibstd::sync::LazyLock(stabilized in Rust 1.80+)
[2.20.0] - 2026-01-05
Section titled “[2.20.0] - 2026-01-05”Dependencies
Section titled “Dependencies”- Updated reqwest to 0.13.1: Migrated to new rustls defaults
- rustls is now the default TLS backend (previously native-tls)
- aws-lc is the default crypto provider (previously ring)
- rustls-platform-verifier is used by default for root certificates
- All reqwest features updated to new naming conventions
- Updated development dependencies: Updated pnpm packages, Ruby gems, and pre-commit hooks
- oxlint pre-commit hook updated from v1.36.0 to v1.37.0
- All language bindings dependencies refreshed
Infrastructure
Section titled “Infrastructure”- Fixed C# package update task: Updated dotnet list command to specify project files explicitly
- Prevents “project or solution file could not be found” errors
- Now checks both HtmlToMarkdown.csproj and HtmlToMarkdown.Tests.csproj individually
[2.19.8] - 2026-01-05
Section titled “[2.19.8] - 2026-01-05”Bug Fixes
Section titled “Bug Fixes”- Blockquote newline preservation: Fixed Issue #176 - Newlines were not preserved when block elements like
<strong>were directly adjacent to<blockquote>elements- Blockquotes now add proper spacing before and after themselves
- Fixed blockquote+paragraph spacing to match CommonMark spec
- Fixed blockquote+HR spacing to avoid extra newlines
- Added comprehensive regression tests to prevent future regressions
- Maintains CommonMark compliance (132/132 tests passing)
Improvements
Section titled “Improvements”- Debug logging cleanup: Removed extensive debug logging from hOCR processing and core converter
- Removed ~30 debug eprintln! statements that were spamming output
- Removed unused debug parameters from hOCR functions (parse_properties, reconstruct_table, extract_hocr_document, etc.)
- Cleaner output and reduced noise during HTML to Markdown conversion
[2.19.7] - 2026-01-03
Section titled “[2.19.7] - 2026-01-03”Improvements
Section titled “Improvements”- Homebrew bottle CI debugging: Added verification steps to diagnose artifact upload/download issues
- Added verification after bottle creation to confirm file exists in workspace
- Added
if-no-files-found: errorto fail fast if bottle file not found during upload - Added verification after artifact download to show what was actually retrieved
- These steps will help identify why Homebrew bottle artifacts aren’t being found in release workflow
[2.19.6] - 2026-01-03
Section titled “[2.19.6] - 2026-01-03”Bug Fixes
Section titled “Bug Fixes”- WASM npm package publishing: Fixed Issue #172 - WASM package was published with only 3 files (LICENSE, package.json, README.md) instead of 25 files
- Root cause: publish workflow downloaded WASM artifact tarballs but never extracted them before running
npm publish - Added extraction step in
.github/workflows/publish.yamlto unpack dist/, dist-node/, and dist-web/ directories - Added safeguard to remove .gitignore files from dist directories that could exclude content
- Complete package now includes all WASM binaries and JavaScript wrappers (7.8 MB unpacked)
- Root cause: publish workflow downloaded WASM artifact tarballs but never extracted them before running
[2.19.5] - 2025-01-02
Section titled “[2.19.5] - 2025-01-02”Bug Fixes
Section titled “Bug Fixes”- Homebrew bottle naming: Fixed bottle filename format to match Homebrew convention
- Changed from double-dash (
html-to-markdown--2.19.x) to single-dash (html-to-markdown-2.19.x) - Homebrew constructs bottle URLs based on formula name and version, expecting single dash separator
- Fixes bottle download failures when installing via
brew install
- Changed from double-dash (
[2.19.4] - 2025-01-02
Section titled “[2.19.4] - 2025-01-02”Bug Fixes
Section titled “Bug Fixes”- Homebrew formula publishing: Fixed publish workflow script that updates the Homebrew tap formula
- Corrected bottle block deletion regex (was looking for
# bottle doinstead ofbottle do), preventing duplicate bottle blocks from accumulating on each release - Added automatic source tarball SHA256 computation and formula update to ensure correct checksums
- Formula now properly replaces old bottle blocks with new ones rather than appending
- Corrected bottle block deletion regex (was looking for
[2.19.3] - 2025-01-02
Section titled “[2.19.3] - 2025-01-02”Bug Fixes
Section titled “Bug Fixes”- Table image processing: Fixed Issue #175 - images inside Blogger-style HTML tables (e.g.,
<table class="tr-caption-container">) were being stripped during conversion. Enhanced table scanner to recognize images as content and properly process non-table elements like<a>and<img>that are direct children of table elements. - WASM npm package: Fixed Issue #172 completely - package was published but missing all WASM binaries and JavaScript wrappers (only 23 KB with 3 files). Created
.npmignoreto includedist/,dist-node/, anddist-web/directories that were excluded by.gitignoreduring npm publish. - PHP Packagist publishing: Fixed version mismatch that caused Packagist to reject v2.19.2 tag. Updated
sync_versions.pyto synchronize both rootcomposer.jsonandpackages/php/composer.json. - Test apps: Fixed relative fixture paths in C#, Java, and Elixir test apps. Updated Elixir tests to handle tuple-returning API. Added Java native library path configuration.
Infrastructure
Section titled “Infrastructure”- Enhanced
sync_versions.pyscript to update rootcomposer.jsonfor Packagist validation - Recreated v2.19.2 git tag with correct composer.json version
[2.19.2] - 2025-12-30
Section titled “[2.19.2] - 2025-12-30”Bug Fixes
Section titled “Bug Fixes”- WASM npm package: Fixed missing
.d.tsfiles in published package by updatingfilesfield with glob patterns (fixes #172) - Test apps: Fixed API mismatches across all language test apps (Python, Node.js, WASM, Go, Java, C#)
- Python: Changed
convert_html_to_markdown()toconvert() - Node.js: Updated to scoped package
@kreuzberg/html-to-markdown - WASM: Changed
convertHtmlToMarkdown()toconvert() - Go: Updated FFI version from 2.16.0 to 2.19.1 with enhanced error handling
- Java: Added Maven wrapper files for portability
- C#: Updated to
KreuzbergDev.HtmlToMarkdownpackage name
- Python: Changed
- Packagist publishing: Added automated workflow job and moved
composer.jsonto repository root - Maven Central publishing: Fixed GitHub secrets configuration (corrected
GPG_PASSPHRASEtypo) - Go bindings: Enhanced FFI download error messages with actionable troubleshooting guidance
- Pre-commit hooks: Fixed Go linting errors (errcheck, staticcheck) and formatting violations
Infrastructure
Section titled “Infrastructure”- Created new WASM test app with comprehensive smoke and integration tests
- Updated all test apps to version 2.19.0 for consistent validation
- Enhanced Java package formatting to comply with 120-character line limit
[2.19.1] - 2025-12-29
Section titled “[2.19.1] - 2025-12-29”Bug Fixes
Section titled “Bug Fixes”- Go formatting: Applied
gofmttopackages/go/v2/htmltomarkdown/visitor.goto align constant declarations - Java tooling: Upgraded google-java-format from 1.21.0 to 1.25.2 for Java 25 compatibility
- Homebrew distribution: Added html-to-markdown formula to kreuzberg-dev homebrew tap for CLI installation
[2.19.0] - 2025-12-29
Section titled “[2.19.0] - 2025-12-29”Breaking Changes
Section titled “Breaking Changes”- npm package namespace: All npm packages now use the
@kreuzbergscope for better organization and discoverabilityhtml-to-markdown-node→@kreuzberg/html-to-markdown-nodehtml-to-markdown-wasm→@kreuzberg/html-to-markdown-wasm
- Java package namespace: Java binding now uses
dev.kreuzbergpackage prefix instead ofcom.goldziher- Updated all Maven artifact IDs and Java package names for semantic clarity
- Affects all public classes and imports in Java projects
- C# namespace: C# bindings now use
KreuzbergDevnamespace instead ofGoldziher- Updated NuGet package ID to
KreuzbergDev.HtmlToMarkdown - All public types now under
KreuzbergDev.HtmlToMarkdownnamespace
- Updated NuGet package ID to
Features
Section titled “Features”- XML table support (TEI/JATS formats): Added support for TEI (Text Encoding Initiative) and JATS (Journal Article Tag Suite) table elements
<row>elements for table rows with proper cell grouping and nesting<cell>elements with full attribute support includingrole="head"for header cells<graphic>elements for figure/image references within cells and content blocks- Proper table structure preservation when converting scientific markup formats
- Aligns with CommonMark table output while respecting source document semantics
Bug Fixes
Section titled “Bug Fixes”- Fixed Clippy warnings across Rust core and all binding crates for cleaner compilation
- Improved test suite with enhanced error messages and edge case coverage
- Refined table element handling for robustness with malformed markup
Infrastructure
Section titled “Infrastructure”- CI/CD improvements: Enhanced C# workflow for improved reliability and platform coverage
- Release distribution: Added Homebrew bottle support for macOS CLI binary distribution
- Version synchronization: All language bindings now synchronized to v2.19.0
[2.18.0] - 2025-12-28
Section titled “[2.18.0] - 2025-12-28”- Visitor Pattern: Complete implementation of visitor pattern for custom HTML element processing across all 8 language bindings (Python, TypeScript, Ruby, PHP, Go, Java, C#, Elixir)
- Synchronous and asynchronous visitor support (where applicable per language)
- 40+ visitor methods with hooks for every HTML element type (text, links, images, headings, lists, tables, code blocks, and more)
NodeContextprovides element metadata: tag name, attributes, depth, parent tag, inline status, and sibling index- Control flow options: Continue, Custom (provide custom markdown), Skip, PreserveHtml, or Error
- Element lifecycle callbacks:
visit_element_startandvisit_element_endfor complete control - Python: Full async visitor support with
convert_with_async_visitor()function - TypeScript: Async visitor with full type definitions
- Ruby: Sync visitor implementation with complete RBS type definitions
- PHP: Full visitor support with PHPStan level 9 compliance
- Go: Thread-safe visitor registry with markdown post-processing
- Java: Panama FFI visitor (JDK 21+)
- C#: P/Invoke visitor with cross-platform compatibility
- Elixir: Rustler NIF visitor implementation
- HTML parsing for modern websites: Fixed issue where JavaScript-heavy websites (like Reuters) would lose article body content during conversion (GitHub issue #167)
- The parser was incorrectly interpreting HTML-like strings inside
<script>tags as actual HTML elements - Script and style tags are now properly stripped during preprocessing while preserving JSON-LD metadata
- No performance impact on conversion speed
- The parser was incorrectly interpreting HTML-like strings inside
- Python API: Fixed missing
ConversionOptionsHandleexport in public API (GitHub issue #166)- Users can now import
ConversionOptionsHandledirectly from thehtml_to_markdownpackage - Maintains backward compatibility with existing
OptionsHandleimport
- Users can now import
[2.17.0] - 2025-12-22
Section titled “[2.17.0] - 2025-12-22”- Go binding now auto-downloads the native FFI library from GitHub Releases with cache/override controls.
- Release pipeline now publishes per-platform Go FFI artifacts for Go installs.
[2.16.1] - 2025-12-22
Section titled “[2.16.1] - 2025-12-22”- Fast-path plain-text conversions now honor escape flags (asterisks/underscores/misc/ASCII).
- Fast-path plain-text conversions now normalize whitespace and trim trailing spaces.
- Fast-path plain-text conversions now respect
strip_newlines. - Python CLI proxy now only applies v1 translation defaults when v1-only flags are present.
[2.16.0] - 2025-12-22
Section titled “[2.16.0] - 2025-12-22”- Profiling harness and workflow for Rust core and bindings with consolidated flamegraph output.
- Benchmark scenarios for inline images, metadata extraction, and raw metadata output across fixtures.
- WASM profiling support with warmups and stable flamegraph parsing.
- FFI byte-based conversion path plus metadata-raw benchmark coverage.
Changed
Section titled “Changed”- Bench harness now supports expanded fixture coverage and results consolidation.
- Java benchmarks align on JDK 25 for consistent profiling runs.
- Node benchmark harness now runs from the package directory and uses native bindings.
- Profiling stability fixes across Go, Elixir, Java, and WASM adapters.
- Binary input detection now flags compressed/magic signatures and UTF-16 data with clearer errors.
Performance
Section titled “Performance”- Rust core conversion: metadata extraction, inline image handling, tag/whitespace caches, and text assembly hot paths.
- Bindings interop: tighter metadata serialization/deserialization paths.
- Rust bench harness (local, Apple M4): median ops/sec improved 18.8× on Wikipedia fixtures (53.7 → 1009.1).
[2.15.0] - 2025-12-19
Section titled “[2.15.0] - 2025-12-19”- Rust core: clamp table
colspan/rowspanto prevent pathological allocations on malformed HTML. - Rust core: reject binary-like inputs early to avoid OOMs when non-HTML data is passed to
convert.
[2.14.11] - 2025-12-16
Section titled “[2.14.11] - 2025-12-16”- C# (NuGet): fix
ConvertWithMetadata()deserialization for metadata enums (link_type,image_type,data_type,text_direction) by honoring the JSON wire values.
[2.14.10] - 2025-12-16
Section titled “[2.14.10] - 2025-12-16”- Python: release the GIL during native conversion so
ThreadPoolExecutorparallelism doesn’t regress performance, and always build the extension with metadata support (soconvert_with_metadatais always available).
[2.14.9] - 2025-12-16
Section titled “[2.14.9] - 2025-12-16”- Structured data: JSON-LD is now extracted from
<script type="application/ld+json">tags (including when placed in<head>), preserving the script contents for parsing.
[2.14.8] - 2025-12-15
Section titled “[2.14.8] - 2025-12-15”- Rust crate (
html-to-markdown-rs): enable themetadatafeature by default soconvert_with_metadatais available without extra Cargo features.
[2.14.7] - 2025-12-15
Section titled “[2.14.7] - 2025-12-15”- Elixir (macOS): package now ships a
.cargo/config.tomlso Rustler can compile without requiring user-specific linker flags.
[2.14.6] - 2025-12-15
Section titled “[2.14.6] - 2025-12-15”- RubyGems publish: skip duplicate
ruby-platform gems when multiple CI jobs produce identical artifacts for the same version. - Hex publish: ensure the Rust core crate is staged into the Elixir package before publishing.
[2.14.5] - 2025-12-15
Section titled “[2.14.5] - 2025-12-15”- RubyGems publish: prevent corrupted gem pushes by downloading
rubygems-*artifacts into separate directories (no merge), and publishing gems recursively with an integrity check.
[2.14.4] - 2025-12-15
Section titled “[2.14.4] - 2025-12-15”- Release pipeline: build the C#
osx-x64native FFI library onmacos-15-intel(macOS-13 runners are retired), unblocking NuGet publication. - Elixir (Hex): package now vendors the Rust core crate so
mix deps.get && mix testworks outside this monorepo.
[2.14.3] - 2025-12-15
Section titled “[2.14.3] - 2025-12-15”- Issue #150 / Discord report: Python now always exports
convert_with_metadata(no moreImportErroron import). - Issue #149: Blockquote text now word-wraps when
wrap=true. - FFI JSON parity: Metadata enums now serialize as snake_case (e.g.
external,relative) to match cross-language expectations. - PHP test runner now always builds the extension with the
metadatafeature enabled (avoids missinghtml_to_markdown_convert_with_metadatawhen the workspace was built with--no-default-features).
- Elixir:
convert_with_metadata/3+MetadataConfigbacked by the Rust metadata extractor.
Changed
Section titled “Changed”- WASM: metadata bindings are enabled by default so the published npm package exports
convertWithMetadata. - C# publish pipeline: stage native
html_to_markdown_ffilibraries into the NuGet package underruntimes/*/native. - Go: module path now uses semantic import versioning (
.../packages/go/v2), and docs/examples were updated accordingly. - Java: add
.sdkmanrcfor Java 25 + Maven 4; keepmaven-source-pluginon3.3.1because4.0.0-beta-1is not compatible with Maven4.0.0-rc-4.
[2.14.2] - 2025-12-13
Section titled “[2.14.2] - 2025-12-13”Changed
Section titled “Changed”- CI/release automation: extracted Maven installer logic into
scripts/common/install-maven-latest.shand applied repo-wide lint/format cleanups.
[2.14.1] - 2025-12-12
Section titled “[2.14.1] - 2025-12-12”- Issue #147: Word wrap now works correctly in list items when using the
-w/--wrapflag. List items with long text are properly wrapped while preserving list structure and indentation for both ordered and unordered lists. - Issue #146:
strip_tagsandpreserve_tagsoptions now correctly prevent<meta>and<title>tags from being extracted into YAML frontmatter whenextract_metadatais enabled. - Issue #145:
strip_newlines=trueno longer causes excessive whitespace around block elements. Structural whitespace is now properly normalized while still removing newlines within paragraph content.
[2.14.0] - 2025-12-11
Section titled “[2.14.0] - 2025-12-11”- CLI Metadata Extraction: New
--with-metadataflag with JSON output support for extracting document metadata, headers, links, images, and structured data from HTML documents.- Six extraction flags:
--extract-document,--extract-headers,--extract-links,--extract-images,--extract-structured-data - JSON output format with markdown and metadata fields:
{"markdown": "...", "metadata": {...}} - Feature enabled by default in CLI builds
- Six extraction flags:
- Go FFI Binding: Complete
ConvertWithMetadata()function with typed structs for metadata extraction.- 12 Go struct types with JSON tags for type-safe metadata access
- JSON unmarshaling from FFI layer
- 18 comprehensive tests covering all metadata types
- Java FFI Binding: Complete
convertWithMetadata()method with Java records for metadata extraction.- 11 Java record types using Panama FFM for FFI integration
- Proper enum types for link/image/text direction (no string-based parsing)
- Jackson JSON deserialization with error handling
- 33 comprehensive tests including negative test cases
- C# FFI Binding: Complete
ConvertWithMetadata()method with C# records for metadata extraction.- 11 C# record types using P/Invoke for FFI integration
- System.Text.Json deserialization with proper error handling
- 23 comprehensive tests covering all metadata types
- FFI Core API: New
html_to_markdown_convert_with_metadata()C function for language-agnostic metadata extraction.- JSON serialization for cross-language compatibility
- Proper memory management and error handling
- 17 comprehensive tests including memory safety tests
Changed
Section titled “Changed”- Documentation Consolidation: Migrated all standalone METADATA.md files into binding READMEs for improved maintainability.
- Deleted
packages/typescript/METADATA.md(480 lines) andpackages/ruby/METADATA.md(228 lines) - Enhanced Python, PHP, TypeScript, Ruby, Go, Java, and C# READMEs with comprehensive metadata sections
- Root README now includes CLI metadata examples and links to all binding documentation
- Each binding README is now self-contained with full metadata documentation
- Deleted
- Type Definitions: Enhanced metadata type definitions across all language bindings.
- Go: Complete struct types with JSON tags and godoc comments
- Java: Proper enum types (LinkType, ImageType, TextDirection) instead of strings
- C#: Complete record types with XML documentation
- Python: Fixed
max_structured_data_sizedefault (100KB → 1MB) - TypeScript: Verified dimensions field type (Array
for compatibility)
- Docstrings: Enhanced documentation strings across all language bindings.
- Rust core: Improved function and module documentation
- Python: Enhanced PyO3 docstrings with examples and type hints
- Ruby: Added YARD tags for better documentation generation
- PHP: Enhanced docblocks with detailed parameter descriptions
- FFI Memory Safety: Fixed critical memory safety bug where error paths could leave dangling metadata pointers.
- Both markdown and metadata pointers now set to null on any error
- Added comprehensive memory safety tests
- CLI Flag Implementation: Fixed
--extract-documentflag not being mapped to MetadataConfig.- Flag now correctly controls document metadata extraction
- Added 9 new CLI tests for metadata flags
- Java Type Safety: Fixed metadata loss and silent failures from missing fields and string-based enums.
- Added dimensions field to ImageMetadata (was missing, causing 50% metadata loss)
- Changed linkType, imageType, textDirection from String to proper enum types
- Fixed exception swallowing in getLastError() - now logs errors and returns descriptive messages
- Python Default Values: Fixed incorrect
max_structured_data_sizedefault (was 100KB, should be 1MB).- Now uses
DEFAULT_MAX_STRUCTURED_DATA_SIZEconstant from Rust core
- Now uses
- Constants Extraction: Eliminated DRY violations by extracting hardcoded magic numbers.
- Added
DEFAULT_MAX_STRUCTURED_DATA_SIZE: usize = 1_000_000constant in Rust core - Reused across FFI, CLI, and Python bindings
- Added
Technical Details
Section titled “Technical Details”- Test Coverage: Added 55 new tests across all bindings (71 → 126 total tests, 77% increase)
- FFI: 13 new tests (4 → 17 total)
- CLI: 9 new tests (67 → 76 total)
- Java: 33 new tests (0 → 33 total)
- Go: 18 tests total
- C#: 23 tests total
- Language Compliance: Achieved 100% compliance across all bindings (up from 50%-100% range)
- All bindings now correctly implement metadata extraction with proper types
- Standardized error handling and JSON parsing patterns
- Documentation: Added 3,500+ lines of comprehensive metadata documentation across all binding READMEs
- Migrated 708 lines from TypeScript and Ruby METADATA.md files
- Enhanced Python and PHP READMEs with extensive examples
- Added metadata sections to Go, Java, and C# READMEs
[2.13.0] - 2025-12-10
Section titled “[2.13.0] - 2025-12-10”- Comprehensive metadata extraction API across all language bindings (Python, TypeScript, Ruby, PHP, WASM).
- New
convert_with_metadata()function returning both markdown and extracted metadata in a single pass. - Metadata extraction includes: document metadata (title, description, keywords, author, language, Open Graph, Twitter Card), header hierarchy (h1-h6 with IDs and nesting), link classification (internal/external/anchor/email/phone), image metadata with type detection (data URIs, inline SVGs, external, relative), and structured data (JSON-LD, Microdata, RDFa).
- Python: 51 comprehensive integration tests with full TypedDict type stubs and mypy validation.
- TypeScript: 14 vitest tests with auto-generated NAPI types, runtime feature detection via
hasMetadataSupport(), and 600+ lines of documentation. - Ruby: 40+ RSpec tests with complete RBS type signatures and comprehensive API documentation.
- PHP: 21 PHPUnit tests with PHPStan level max compliance and readonly Value Objects.
- WASM: Complete metadata extraction with serde_wasm_bindgen serialization and getter/setter configuration structs.
Changed
Section titled “Changed”- Enabled metadata feature by default in TypeScript and Ruby bindings for production npm packages and gems.
- Updated all language binding versions to 2.13.0 with synchronized version management.
- Ruby: Added missing wrapper method for
convert_with_metadataand fixed redundant?symbols in RBS type annotations. - TypeScript: Enabled metadata feature in default Cargo features to ensure npm packages include metadata functionality.
- WASM: Fixed 3 clippy style violations (Default trait implementation, unwrap_or_default usage, struct initialization pattern).
[2.12.1] - 2025-12-09
Section titled “[2.12.1] - 2025-12-09”- Escape literal
|characters inside table cells while leaving pipes inside<code>and<pre>untouched to avoid rendering backslashes in code spans/blocks (fixes #140). - Handle nested tables without double-escaping pipes and add regression coverage for table cells containing code spans/blocks and nested tables.
- Preserve link-only list items when word wrapping is enabled so nested link lists are not merged or reflowed (fixes #143); added regression fixtures for the reported table-of-contents sample.
Changed
Section titled “Changed”- Updated dependency locks/manifests to align with the 2.12.1 release.
- Downgraded Java Maven compiler/source plugins back to 3.x to keep CI builds compatible with Maven 3 runners.
[2.12.0] - 2025-12-08
Section titled “[2.12.0] - 2025-12-08”- WebAssembly bundler target now supports Cloudflare Workers, Wrangler, and modern bundlers that provide
WebAssembly.Moduleinstead ofWebAssembly.Instance. - Three new WASM usage examples demonstrating different deployment targets:
examples/wasm-node: Node.js example using dist-node targetexamples/wasm-rollup: Browser example using dist-web target with Rollupexamples/wasm-cloudflare: Cloudflare Workers example using bundler target with Wrangler
Changed
Section titled “Changed”- WASM bundler entry point now detects and handles
WebAssembly.Moduleinstances, building the proper import namespace for wasm-bindgen glue functions.
[2.11.4] - 2025-12-08
Section titled “[2.11.4] - 2025-12-08”- Node/WASM bundles now post-process their generated JS files to import the shared
WasmConversionOptionstypedef and emit typed doc comments (including typed inline-imageattributes), so noanyannotations leak into the publisheddist,dist-node,dist-web, or docs bundles.
[2.11.3] - 2025-12-08
Section titled “[2.11.3] - 2025-12-08”- Prevent link-label truncation from splitting multi-byte characters, which previously triggered a
PanicExceptionin the Python bindings when processing long anchors (resolves #139) and add a regression test to keep the truncation logic safe.
[2.11.2] - 2025-12-07
Section titled “[2.11.2] - 2025-12-07”- Explicitly ship typing artefacts in every binding: npm packages export
.d.tsfiles by default, Ruby gems now includesig/**/*.rbseven when building outside git, and the Python wheel bundles_html_to_markdown.pyiplus apy.typedmarker for static type checkers.
- Cleaned up the Python API’s inline-image helper to avoid redundant casts flagged by
mypy --strict. - Tightened PHP docblocks and psalm/phpstan annotations so option arrays use strongly typed shapes instead of
array<string, mixed>. - Hardened the WASM, Node, and Python bindings so their
optionsargument is fully typed end-to-end (noanyescapes in.d.tsfiles or placeholderAnyannotations).
[2.11.1] - 2025-12-05
Section titled “[2.11.1] - 2025-12-05”- Preserve indentation in
<pre><code>blocks while safely dedenting whitespace across multibyte characters to avoid panics when leading spaces are non-ASCII; regression fixture added for issue #134. Thanks @bbeardsley for the contribution.
[2.11.0] - 2025-12-04
Section titled “[2.11.0] - 2025-12-04”- CLI
--urlflag with optional--user-agentoverride to fetch remote HTML directly, plus charset-aware decoding. - New GitHub Pages deploy workflow to publish the
docs/demo frommain. - Additional CLI integration tests covering URL fetching (including custom UA, legacy markup, frameset/noframes, cp1252 decoding).
Changed
Section titled “Changed”- Demo layout now keeps input/output panes equal height and responsive.
- Rust core handles body-like content accidentally nested in
<head>more gracefully.
[2.10.1] - 2025-12-02
Section titled “[2.10.1] - 2025-12-02”- Normalize whitespace inside link labels (collapse newlines and extra spaces) so anchors with messy HTML do not emit multi-line
[]text. - Flatten block children inside
<a>(e.g., headings/paragraphs nested in anchors) into a single Markdown link instead of duplicating content; regression tests added for the reported Arabic product card case.
Changed
Section titled “Changed”- Synced all workspace/package versions to 2.10.1 via
task sync-versions.
[2.10.0] - 2025-12-02
Section titled “[2.10.0] - 2025-12-02”- Centralized panic guarding for all bindings (Python, Node, PHP, WASM, C FFI) using a shared Rust helper so panics surface as language-native errors instead of unwinding across FFI boundaries.
- C FFI now stores the last error per thread and exposes it via
html_to_markdown_last_error, with panic and UTF-8/null input diagnostics. - Ruby binding now uses the shared panic guard and emits consistent panic messages; specs cover panic interception across conversion entrypoints.
Changed
Section titled “Changed”- Wasmtime test harness initializes conversion options via struct literals to reduce clippy noise in CI.
- Rust coverage CI now forces
cargo-llvm-covreinstall to avoid cached binary conflicts on GitHub runners. - PHP smoke tests use the Packagist package name
goldziher/html-to-markdown, matching README install instructions.
[2.9.3] - 2025-12-01
Section titled “[2.9.3] - 2025-12-01”Changed
Section titled “Changed”- Version sync – Bumped the entire workspace (Rust, Python, npm, Ruby, Elixir, Java, C#, Go) to 2.9.3 via
task sync-versionsto prep the next patch release. - Docs & install commands – Pointed all Composer references to the published
goldziher/html-to-markdownpackage and clarified npm usage to the shipped packages (html-to-markdown-node/html-to-markdown-wasm).
- Go lint CI – Replaced the invalid
go fmt -linvocations withgofmt -lin the Taskfile sotask check/CI lint runs complete successfully on Go 1.25.
[2.9.2] - 2025-11-28
Section titled “[2.9.2] - 2025-11-28”- UTF-8 safety (Fix #127) – Guarded whitespace trimming against mid-codepoint truncation, eliminating byte-boundary panics on multilingual documents; added fixture and regression test for the reported Ruby-path crash.
- Image conversion (Fix #128) –
<img>elements withwidth/heightnow render as Markdown images instead of raw HTML; regression test covers inline-data URIs with dimensions.
[2.9.1] - 2025-11-22
Section titled “[2.9.1] - 2025-11-22”Changed
Section titled “Changed”- HTML repair fallback – Minified or malformed pages now reparse via html5ever when inline/block nesting is broken, keeping content that previously vanished (e.g., SPA shells and Hacker News markup).
- Link label recovery – Anchor text fallback prefers child formatting or hrefs only when appropriate, preventing empty labels while keeping CommonMark empty-link semantics intact.
- Layout tables to lists – Headless tables with mixed column counts/spans or nested tables render as list rows instead of broken Markdown tables, restoring Hacker News output.
- Issue 121 regressions – Added fixtures/tests for the empty SPA and malformed Hacker News samples; both now produce full Markdown content without frontmatter noise.
[2.9.0] - 2025-11-20
Section titled “[2.9.0] - 2025-11-20”- Elixir bindings – New
html_to_markdownHex package built with Rustler, exposing the Rust core converter to Elixir with configurable options plusconvert/2andconvert!/2. - WASM runtime verification – Added a Wasmtime-backed e2e suite (
e2e/wasm-wasmtime) plustask wasm:test:wasmtimeto compile thehtml-to-markdown-wasmartefact forwasm32-unknown-unknownand execute it inside Wasmtime. CI now runs these tests to ensure the WASM package works outside the browser runtime.
Changed
Section titled “Changed”- Astral
tlparser – The HTML parser dependency now points to the actively maintainedastral-tlfork (still imported astl) so comment parsing stays up to date with upstream fixes. - NuGet Package ID – C# bindings now publish under
Goldziher.HtmlToMarkdownto avoid clashing with an existing community package. - Wasmtime CI Coverage – The Wasmtime e2e job now runs on Linux x64, Linux arm64, macOS, and Windows runners so every GitHub-hosted architecture executes the WASM tests.
- PHP PIE source bundle – Release packaging strips the Wasmtime e2e workspace from the staged
Cargo.toml, fixing the “failed to load manifest” error in the publish workflow. - Horizontal rule rendering –
<p>…</p><hr>now emits a blank line before---while preserving blockquote spacing so the rule is never misinterpreted as a setext heading. - Empty HTML comments – Zero-width
<!---->comment nodes are normalized before parsing, so comment placeholders no longer cause the following content to disappear.
[2.8.3] - 2025-11-15
Section titled “[2.8.3] - 2025-11-15”Changed
Section titled “Changed”- Deterministic uv installs – Every
uv syncinvocation in CI and the Taskfile now runs with--no-install-workspace, ensuring Python dependencies are resolved without mutating editable installs before the subsequent build/test steps run.
- NuGet Publishing – Release automation now uses GitHub’s trusted publisher flow via
NuGet/login@v1(OIDC → short-lived API key) before pushing artifacts, removing the dependency on long-lived secrets. - Hex Publishing – The release workflow invokes
mix hex.publish --yesfrompackages/elixir, withex_docbundled as a dev dependency so documentation generation works during release.
[2.8.2] - 2025-11-15
Section titled “[2.8.2] - 2025-11-15”Changed
Section titled “Changed”- Unified Version Sync –
scripts/sync_versions.pynow updates Elixir@versiondeclarations, the C#.csproj, and the Javapom.xml(alongside every npm/pyproject/Gemfile manifest).task sync-versionsbumps the entire multi-language stack to 2.8.2 in one shot. - CI / Release Toolchains – GitHub Actions now installs Elixir dependencies ahead of Credo and runs on Elixir 1.19 + OTP 28.1, matching the README prerequisites and preventing per-job regex recompilation warnings.
- Taskfile Coverage – Added
elixir:updateplus fulljava:{install,update,test,lint}tasks sotask setup,task update,task test, andtask lintcover every published runtime (Go, C#, Elixir, Java) just like the CI workflows.
[2.8.1] - 2025-11-15
Section titled “[2.8.1] - 2025-11-15”- Release Pipeline – Bumped all package manifests to v2.8.1 so the publish workflow can push fresh artifacts after the v2.8.0 smoke-test fixes (PyPI, npm, and RubyGems refuse re-uploads of the same version).
[2.8.0] - 2025-11-15
Section titled “[2.8.0] - 2025-11-15”- Java, C#, and Go Bindings (First Release) – First public release of official Java (JNA), C# (.NET), and Go (CGO) language bindings. All three are integrated into the unified
task bench:bindingsharness and ship with comprehensive performance data in their READMEs. C# leads at ~1.4k ops/sec (≈171 MB/s), Go at ~1.3k ops/sec (≈165 MB/s), and Java at ~1.0k ops/sec (≈126 MB/s) on the 129 KB Wikipedia lists fixture.
Changed
Section titled “Changed”- BREAKING: Preprocessing Disabled by Default – HTML preprocessing is now disabled by default in the library API to prevent silent content loss. Previously,
<nav>,<form>, and related elements (along with all their children) were dropped by default, causing important content inside these tags to be lost. Users who want preprocessing must now explicitly enable it viaPreprocessingOptions { enabled: true, ... }. The CLI behavior is unchanged (preprocessing has always been opt-in with--preprocess). - Rust Toolchain Settings – All crates (including the Ruby binding) now inherit
edition = "2024"andrust-version = "1.85"from the workspace to keep toolchain configuration centralized. - GitHub Actions Workflow DRY – Created 17 reusable composite actions (8 build actions + 9 smoke test actions) to eliminate ~267 lines of duplication between CI and publish workflows.
- Toolchain Management – Migrated to official GitHub Actions parameters for Ruby Bundler 2.7.2 and PHP Composer 2.9.1, removing manual installation scripts.
- Windows PHP Extension Build – Replaced php-windows-builder orchestration with direct
cargo buildmatching ext-php-rs’s proven approach, resolving LLVM 19 MMX header incompatibilities and Zend symbol linking errors. - Linux PHP Build – Added php-config path capture and parameter passing to build-php-linux action, fixing “php-config executable not found” errors.
- Ruby Linux Build – Set LD_LIBRARY_PATH on Linux builds to match magnus best practices, preventing potential “strings.h not found” errors.
- golangci-lint CI – Split golangci-lint pre-commit hook into separate invocations for
packages/goandexamples/go-smokemodules, fixing “directory prefix does not contain main module” errors by running each check from within its Go module directory. - Windows Go CGO Smoke Test – Documented MSVC/MinGW toolchain incompatibility and skip Windows Go smoke test with informative message, as Go CGO uses MinGW which cannot link against MSVC-compiled Rust FFI libraries.
- Go Code Quality – Removed redundant newline in
examples/go-smoke/main.gofmt.Println call (detected by newly-working golangci-lint).
[2.7.2] - 2025-11-12
Section titled “[2.7.2] - 2025-11-12”- Node/WASM Binding Regression – HTML preprocessing no longer drops
<html>,<head>, or<body>wrappers when their classes resemble navigation chrome, so large Wikipedia fixtures once again emit full markdown (restoring the Vitest length/table expectations for Node bindings and keeping WASM conversions consistent). - Cloudflare WASM Initialization – Bundler builds of
html-to-markdown-wasmnow exposeinitWasm()/wasmReadyso edge runtimes that instantiate WebAssembly modules asynchronously (Cloudflare Workers, Vite dev servers, etc.) can await initialization before callingconvert(), eliminating the__wbindgen_startruntime error. - Footer Retention (Fix #120) – The Rust preprocessor keeps plain
<footer>content unless the element carries explicit navigation hints (role/class/id). Python and Rust conversions once again preserve footer copy while still stripping true navigation footers such as.site-footermenus. - Release Smoke Coverage – The publish workflow now downloads the built artifacts (Node, WASM, Python wheels, Ruby gems, PHP zips) and reruns the README smoke installs across Linux/macOS/Windows before any packages are uploaded, ensuring we’re testing the exact bits we ship.
[2.7.1] - 2025-11-12
Section titled “[2.7.1] - 2025-11-12”- Language-Specific Benchmarks – Every binding README (Node, WASM, Python, Ruby, PHP, TypeScript) now publishes the latest
task bench:bindingsthroughput numbers so runtime documentation stays aligned with the shared fixtures. - Examples/Smoke Suite – Added
examples/{node,wasm,python,ruby,php,rust}-smokeplus an overview README to exercise both the published artifacts and local builds before a release.
Changed
Section titled “Changed”- Docs Accuracy – Node/WASM READMEs now clearly reference the real npm packages (
html-to-markdown-node,html-to-markdown-wasm) and provide correct import samples. - TypeScript README – Highlights that the CLI wrapper inherits the native Node benchmarks.
- Repository Hygiene –
.gitignorenow drops.venv/, vendor directories, and nestednode_modules/so smoke tests and language-specific toolchains don’t dirty the tree. - Ruby Build Metadata –
extconf.rbuses a relative path for the embedded Cargo crate and the crate’sCargo.tomlnow declares explicitedition,rust-version, and dependency pins, allowinggem installoutside the workspace. - Version Sync Script –
scripts/sync_versions.pyupdates everyhtml-to-markdown-rsdependency pin (workspace root plus downstream crates) to keep cross-language releases in lockstep.
- Smoke Test Coverage – Verified Node, WASM, Python, Ruby (local gem), PHP (Composer path repo), and Rust installs; documented gaps where external registries still need to publish
goldziher/html-to-markdownorhtml-to-markdown2.7.1 before release.
[2.7.0] - 2025-11-12
Section titled “[2.7.0] - 2025-11-12”- Zero-Copy Inline Images – Node/N-API and WASM bindings now expose
convertInlineImagesBuffer/convertBytesWithInlineImages, letting benchmark harnesses feedBuffer/Uint8Arraydata directly without creating intermediate JS strings.
Changed
Section titled “Changed”- Rust Core Preprocessing – HTML normalization (self-closing fixes, malformed
<escaping, script/style stripping) now happens in a single streaming pass that hands owned buffers straight totl::parse_owned, cutting multiple allocations from every conversion. - Benchmark Harness + Docs – Re-ran the cross-language runtime suite after the Rust core optimizations and refreshed the README tables, keeping the published throughput numbers (Node/Python/Rust/WASM/PHP) in sync with
tools/runtime-bench/results/latest.json. - Version Alignment – Bumped every package (Rust crates, npm packages, PyPI distribution, Ruby gem, PHP extension, WASM bundle) to
2.7.0viatask sync-versions.
- Ruby Benchmark Output – The Ruby benchmark driver now emits JSON without relying on
jsonnative extensions, preventinglibrubyincompatibility errors duringtask bench:bindings. - Nested
<strong>Normalization (Fix #111) – The Rust converter now tracks when bold markup is already active, so nested<b>/<strong>combinations (including<mark>,<summary>,<legend>) no longer generate****artifacts (<b>bo<b>ld</b>er</b>correctly becomes**bolder**). The CommonMark harness documents the four spec examples that expect stacked markers and skips them accordingly. - Heading Whitespace (Fix #118) – ATX/Setext headings swallow layout-only newlines and indentation inside
<h1>…<h6>so pretty-printed HTML like<h2>Heading\n Text</h2>renders as a single Markdown heading line. - Inline Whitespace Preservation – Reworked the inline text pipeline so removing zero-width inline elements (e.g.,
<input>,<script>, empty<b>) no longer collapses surrounding spaces; fixtures liketest_chomp,test_form_with_inputs_inline_mode, and checkbox/task-list rendering now match their expected double-space gaps. - DOCTYPE Handling (Fix #119) –
<!DOCTYPE …>declarations are stripped during preprocessing so they never leak as strayPUBLIC…text in the output, even when metadata extraction is enabled.
[2.6.6] - 2025-11-10
Section titled “[2.6.6] - 2025-11-10”Changed
Section titled “Changed”- Ruby Gem Packaging – Moved the
html-to-markdown-rbcrate underpackages/ruby/ext/html-to-markdown-rb/nativeand pointedextconf.rbat that path so every published gem now contains the Cargo sources it needs to compile on install. - Documentation Consistency – Updated the root, crate, and package READMEs to drop references to the unrelated
html-to-markdownnpm package and to consistently list our supported targets (Node, WASM, Python, Ruby, PHP, CLI). - Dependency Refresh – Ran
task updateto upgrade Rust crates, npm packages, Bundler gems, Python requirements, and Composer dependencies across the monorepo.
- Rust Clippy Lints – Addressed
clippy::unnecessary-map-orin the converter and hOCR table builder by using.is_none_or, keeping inline-image filtering and column pruning logic clear while allowingcargo clippy -D warningsto pass. - PIE Source Packaging –
scripts/package_php_pie_source.shnow copiespackages/ruby/.../nativeinto the temporary workspace so the Ruby crate exists when PIE builds the PHP extension.
[2.6.3] - 2025-11-07
Section titled “[2.6.3] - 2025-11-07”- Release Pipeline - Fixed missing
is_tagoutput in publish workflow that caused all publishing jobs to be skipped - Node.js Package Dependencies - Added missing
optionalDependenciesto html-to-markdown-node package.json to properly link platform-specific binaries - Version Management - Created centralized version sync script (
scripts/sync_versions.py) to maintain consistency across all package manifests (Rust, Node.js, Python, Ruby, WASM) - Cargo Workspace - Aligned html-to-markdown-rb crate version (was 2.5.7) with workspace version
Changed
Section titled “Changed”- Added
task sync-versionscommand to Taskfile for easy version synchronization across the monorepo
[2.6.2] - 2025-11-07
Section titled “[2.6.2] - 2025-11-07”- Table Rowspan Support - Fixed tables with rowspan cells to correctly duplicate cell content across spanned rows instead of showing empty cells (fixes #116)
- Node.js Platform Package Publishing - Fixed workflow to correctly move packed .tgz files to npm directory for publishing
- Deprecation Warnings - Updated CLI tests to use
CARGO_BIN_EXEenv var instead of deprecatedcargo_binmethod - Deprecation Warnings - Replaced deprecated
criterion::black_boxwithstd::hint::black_boxin benchmarks - Clippy Warnings - Fixed field assignment warnings by using struct initialization with defaults
[2.6.1] - 2025-11-07
Section titled “[2.6.1] - 2025-11-07”- Node.js Platform Packages - Fixed publishing of platform-specific npm packages. The workflow now correctly packs npm directories into .tgz files before publishing, ensuring all platform bindings (linux-x64-gnu, darwin-arm64, win32-x64-msvc, etc.) are published to npm.
- WASM Package Publishing - Added proper WASM package publishing workflow to ensure html-to-markdown-wasm is published to npm registry.
[2.6.0] - 2025-11-07
Section titled “[2.6.0] - 2025-11-07”- PHP Extension Support - Official PHP extension (
goldziher/html-to-markdown) providing native HTML to Markdown conversion for PHP 8.2+- Built with ext-php-rs for high-performance Rust-backed conversion
- Supports both Thread-Safe (TS) and Non-Thread-Safe (NTS) builds
- Available for Windows (x86, x64), Linux, and macOS
- Distributed via PIE (PHP Installer for Extensions) source bundles
- Prebuilt Windows binaries for PHP 8.2, 8.3, and 8.4
- Comprehensive test suite with PHPUnit
Changed
Section titled “Changed”- Refactored PHP build variable names from
HTM2MD_*toHTMLTOMARKDOWN_*for improved clarity in Makefile.frag and config.m4 - Bumped all package versions to 2.6.0 across Rust crates, npm packages, PyPI wheels, Ruby gem, and PHP extension
[2.5.7] - 2025-11-03
Section titled “[2.5.7] - 2025-11-03”- Publish Windows PHP extension binaries alongside the PIE source bundle during the release pipeline, enabling one-click installs on every platform.
- Build and archive the CLI binary for Linux (gnu & musl), macOS arm64, and Windows x86_64, plus ship prebuilt WASM bundles (dist/dist-node/dist-web) so every runtime gets first-class artifacts.
Changed
Section titled “Changed”- Renamed the PHP extension package to
goldziher/html-to-markdown, moved the Composer metadata to the repository root, and refreshed the documentation/badges for every language target. - Bumped every package (Rust crates, npm packages, PyPI wheels, Ruby gem, PHP extension) to version 2.5.7.
- Restored the Node.js N-API build matrix so macOS, Windows, and Linux binaries ship automatically with each npm release.
- Preserve ordered list numbering and indentation when list items render headings or HTML tables, so mixed block content stays under the correct bullet (fixes #107).
[2.5.6] - 2025-10-30
Section titled “[2.5.6] - 2025-10-30”Changed
Section titled “Changed”- The Ruby gem now packages its own README at the gem root, so RubyGems renders the fully formatted documentation (benchmarks, configuration, CLI notes) without broken links.
- Documentation links: the Ruby README now surfaces GitHub resources (issues, changelog, live demo) alongside feature highlights.
- Bumped every package (Rust crates, npm, PyPI, Ruby gem) to version 2.5.6.
[2.5.5] - 2025-10-30
Section titled “[2.5.5] - 2025-10-30”Changed
Section titled “Changed”- Synced documentation: the root README now links to every language guide, and the Ruby README highlights GitHub resources alongside feature docs.
- Gem packaging now reads the README directly for the RubyGems long description while keeping Rubocop happy on all Ruby sources.
- Bumped every package (Rust crates, npm, PyPI, Ruby gem) to version 2.5.5.
[2.5.4] - 2025-10-30
Section titled “[2.5.4] - 2025-10-30”Changed
Section titled “Changed”- Polished the Ruby gem messaging and README with performance highlights, configuration examples, and CLI guidance to match other language docs.
- Bumped every package (Rust crates, npm, PyPI, Ruby gem) to version 2.5.4.
[2.5.3] - 2025-10-30
Section titled “[2.5.3] - 2025-10-30”Changed
Section titled “Changed”- Publish Ruby gems as precompiled artifacts for Linux (x86_64), macOS (arm64 & x86_64), and Windows (x64) via a matrix GitHub Action, ensuring the CLI executable matches the target platform.
- Split the release workflow into prepare/build/publish stages so dry runs build artifacts without pushing, and trusted publishing now uploads every generated
.gem. - Hardened the gem preparation script to clear stale CLI binaries before copying in the platform-specific build output.
- Re-enabled the cross-language release workflow so crates.io, PyPI wheels/sdist, and both npm packages ship alongside the Ruby release.
[2.5.2] - 2025-10-29
Section titled “[2.5.2] - 2025-10-29”- Fix Ruby gem packaging to embed standalone Cargo manifest (no workspace inheritance) so installs compile out of tree successfully.
- Bump versions across Rust, Node, Python, and Ruby bindings.
[2.5.1] - 2025-10-28
Section titled “[2.5.1] - 2025-10-28”- Magnus-based Ruby gem (
html-to-markdown-rb) with CLI proxy and comprehensive specs.
Changed
Section titled “Changed”- CI now includes Ruby coverage across macOS, Linux, and Windows, installing the appropriate toolchains (MSYS2 on Windows) for Magnus builds.
- Release workflow prepares the Ruby gem via trusted publishing alongside existing crates/npm packages.
- Bundler version pinned to 2.5.12 to support Ruby 3.2 CI environments.
[2.5.0] - 2025-10-24
Section titled “[2.5.0] - 2025-10-24”- New
preserve_tagsoption - Preserve specific HTML tags in their original HTML form instead of converting them to Markdown. This is useful for complex elements like tables that may not convert well to Markdown. Fixes issue #95.- Accepts a list of tag names (e.g.,
["table", "form"]) - Preserves all attributes and nested content as HTML
- Works independently of
strip_tags- can use both options together - Available in all bindings: Rust, Python, Node.js, and WASM
- Comprehensive test coverage in Rust, Python (pytest), and TypeScript (vitest)
- Accepts a list of tag names (e.g.,
Changed
Section titled “Changed”- HTML preprocessing is now enabled by default - The
PreprocessingOptions.enableddefault changed fromFalsetoTrueto ensure robust handling of malformed HTML. Users who want minimal preprocessing can explicitly setenabled=False.
- Task list checkbox support - Fixed sanitizer removing
<input type="checkbox">elements whenremove_formsis enabled (default). Checkboxes are now preserved during preprocessing to enable proper task list conversion (- [x]/- [ ]).- Added
inputtag to allowed tags in all sanitization presets (minimal, standard, aggressive) - Preserved
typeandcheckedattributes on input elements - Fixed pre-existing bug where task list checkboxes were silently removed
- Added
- Data URI support for inline images - Fixed sanitizer stripping
data:URLs from image src attributes. Base64-encoded inline images (data URIs) are now preserved during preprocessing.- Added
datato allowed URL schemes in all sanitization presets - Fixes
convert_with_inline_imagesfunctionality for base64-encoded images
- Added
- CDATA section handling - Fixed test expectation for CDATA sections. CDATA sections are now correctly preserved as-is during HTML parsing instead of being partially stripped.
- hOCR word spacing - Fixed missing whitespace between
<span class="ocrx_word">elements in hOCR documents. Words now have proper spaces between them.- Modified
OcrxWordconverter to insert space before each word if output doesn’t end with whitespace or markdown formatting characters - Ensures proper word separation in OCR-generated documents without breaking markdown formatting (e.g.,
*text*,[alt](url),`code`)
- Modified
- hOCR detection with preprocessing - Fixed hOCR documents not being detected when HTML preprocessing is enabled (new default). The sanitizer now preserves:
classattributes on all elements (required for detecting hOCR element types)<meta>tags withnameandcontentattributes (required for hOCR metadata detection)<head>tags (container for meta tags)
- hOCR metadata extraction after sanitization - Fixed metadata extraction failing when preprocessing strips the
<head>container element. The extractor now finds orphaned meta tags anywhere in the document, not just inside<head>elements. preserve_tagsfunctionality with preprocessing - Fixedpreserve_tagsnot working when HTML preprocessing is enabled (the new default). The sanitizer now:- Accepts the
preserve_tagslist and allows those tags through sanitization - Preserves common HTML attributes (
id,class,style,title, etc.) on preserved tags - Prevents
remove_formsfrom stripping form tags when they’re in the preserve list - Ensures tags and attributes survive preprocessing so they can be output as HTML
- Accepts the
- SVG support for inline image extraction - Fixed SVG elements being stripped by the sanitizer, breaking inline image capture. All sanitization presets now allow:
- SVG elements:
svg,circle,rect,path,line,polyline,polygon,ellipse,g - SVG attributes:
width,height,viewBox,cx,cy,r,x,y,d,fill,stroke - Enables
convert_with_inline_imagesto capture inline SVG elements
- SVG elements:
- Robust handling of malformed angle brackets in HTML - Fixed parser failures when bare
<or>characters appear in HTML text content (e.g.,1<2, mathematical comparisons). The converter now:- Automatically escapes malformed angle brackets that aren’t part of valid HTML tags
- Works correctly with preprocessing both enabled and disabled
- Handles edge cases like
1<2,1 < 2 < 3, and angle brackets at tag boundaries - Fixes issue #94 where content following malformed angle brackets was lost
- Added comprehensive test coverage for malformed angle bracket handling in both Rust and Python test suites
- Fixed WASM build configuration to use correct
getrandombackend for wasm32-unknown-unknown targets
[2.4.1] - 2025-10-22
Section titled “[2.4.1] - 2025-10-22”- Ensure npm publishes include the generated Node bindings and platform binaries by running the N-API build during CI.
- Configure WebAssembly builds with the
wasm_jsbackend and strip wasm-pack.gitignorefiles so published packages ship the compiled.wasmartifacts.
[2.4.0] - 2025-10-22
Section titled “[2.4.0] - 2025-10-22”Changed
Section titled “Changed”- Updated Rust workspace dependencies (including
pyo3) to their latest compatible releases and refreshed lockfiles. - Normalized hOCR conversion spacing by collapsing stray triple newlines, ensuring generated Markdown matches regression fixtures.
- Corrected the WASM crate to depend on
getrandom’swasm_jsfeature, restoring WebAssembly builds. - Expanded the Node package
fileslist so published tarballs now include compiled.nodeartifacts, CommonJS shims, and typings.
[2.3.4] - 2025-10-12
Section titled “[2.3.4] - 2025-10-12”Changed
Section titled “Changed”- Incremented all distribution metadata and CLI version checks to 2.3.4 following the previous release tag conflict.
- Regenerated package metadata artifacts for the new patch release.
[2.3.3] - 2025-10-12
Section titled “[2.3.3] - 2025-10-12”- Python API now exports inline image helpers (
InlineImage,InlineImageWarning, andInlineImageConfig) alongsideconvert_with_inline_images, with dedicated regression tests. - Node and WASM bindings include inline image extraction examples and TypeScript definitions, validated by Vitest coverage.
Changed
Section titled “Changed”- Bumped all package metadata (Python, Rust, Node, WASM, CLI) to version 2.3.3 for a synchronized release.
- CLI
--versiontest updated to assert the new release number.
[2.2.0] - 2025-10-11
Section titled “[2.2.0] - 2025-10-11”hocr_spatial_tablesoption onConversionOptions(Rust, Python, CLI) with--no-hocr-spatial-tablesflag to disable spatial table reconstruction when desired.- New hOCR regression fixtures for complex tables and code blocks to guard against formatting regressions.
Changed
Section titled “Changed”- Improved hOCR conversion heuristics to distinguish between dense paragraph layouts and true tables, yielding cleaner Markdown for scientific data.
- hOCR code-block detection now preserves fenced formatting, restoring context headings when present.
- CLI
--versionoutput and package metadata now report version 2.2.0 consistently.
[2.1.1] - 2025-10-11
Section titled “[2.1.1] - 2025-10-11”- Improve hOCR table reconstruction when tables are represented as paragraphs, ensuring Markdown tables are emitted for Tesseract outputs without explicit
ocr_tablemarkers.
[2.1.0] - 2025-10-11
Section titled “[2.1.0] - 2025-10-11”- Inline image extraction - New
convert_with_inline_images()function to extract embedded images during conversion- Supports data URI images (
data:image/*) - Supports inline SVG elements
- Configurable via
InlineImageConfigwith options for:- Maximum decoded size limits
- Custom filename prefixes
- SVG capture control
- Optional dimension inference for raster images
- Returns
HtmlExtractionwith markdown, extracted images, and warnings - Available through both Rust and Python APIs
- Supports data URI images (
Changed
Section titled “Changed”- Simplified API - Removed
ParsingOptionsclass in favor of directencodingparameter onConversionOptions - Automatic hOCR table extraction - hOCR tables are now extracted automatically without requiring configuration
- Removed
hocr_extract_tablesoption (always enabled for hOCR content) - Removed
hocr_table_column_thresholdoption (uses built-in heuristics) - Removed
hocr_table_row_threshold_ratiooption (uses built-in heuristics)
- Removed
- Updated pre-commit hook versions (commitlint v9.23.0, pyproject-fmt v2.10.0, ruff v0.14.0)
- hOCR metadata now uses YAML frontmatter instead of HTML comments for cleaner markdown output
- hOCR code organization - Restructured spatial table reconstruction into dedicated
hocr/spatial.rsmodule - Conservative table detection - hOCR spatial table reconstruction now only applies to explicit
ocr_tableelements, preventing false positives - Windows CLI binary detection - now correctly searches for
.exeextension on Windows - CLI binary bundling in Python wheels - binary now included in package for all platforms
- hOCR extractor Rust doctest - added missing import statement
- 928 Python test expectations updated for CommonMark-compliant v2 defaults
- Python 3.14-dev → Python 3.14 stable in CI workflows
- Reorganized wheel preparation script to
scripts/directory - Removed duplicate markdown documentation files (BENCHMARKS.md, PERFORMANCE.md, BENCHMARK_RESULTS.md, COMMONMARK_COMPLIANCE.md, REFACTORING_SUMMARY.md)
2.0.0 - 2025-10-03
Section titled “2.0.0 - 2025-10-03”🚀 Major Rewrite: Rust Backend
Section titled “🚀 Major Rewrite: Rust Backend”Version 2.0.0 represents a complete rewrite of html-to-markdown with a high-performance Rust backend, delivering 10-30x performance improvements while maintaining full backward compatibility through a v1 compatibility layer.
⚠️ Breaking Changes
Section titled “⚠️ Breaking Changes”CommonMark-Compliant Defaults
Section titled “CommonMark-Compliant Defaults”V2 adopts CommonMark-compliant defaults for better interoperability:
| Option | V1 Default | V2 Default | Reason |
|---|---|---|---|
list_indent_width |
4 | 2 | CommonMark standard |
bullets |
“-” | “*+-” | Cycling bullets for nested lists |
escape_asterisks |
true | false | Minimal escaping |
escape_underscores |
true | false | Minimal escaping |
escape_misc |
true | false | Minimal escaping |
newline_style |
“backslash” | “spaces” | CommonMark two-space line breaks |
code_block_style |
“backticks” | “indented” | CommonMark 4-space indent |
heading_style |
“underlined” | “atx” | CommonMark # headings |
preprocessing.enabled |
false | false | No change (opt-in) |
Migration: If you relied on v1 defaults, explicitly set options to match v1 behavior.
Removed CLI Flags
Section titled “Removed CLI Flags”The following v1 CLI flags are not supported in v2. The Python CLI proxy will raise helpful error messages when these flags are used:
| Removed Flag | Reason | Migration |
|---|---|---|
--strip |
Feature removed in v2 | Remove flag (feature no longer available) |
--convert |
Feature removed in v2 | Remove flag (feature no longer available) |
Note on Redundant Flags: The following v1 flags are redundant in v2 (they match the defaults) but are silently accepted for backward compatibility:
--no-escape-asterisks,--no-escape-underscores,--no-escape-misc(v2 defaults to minimal escaping)--no-wrap(v2 defaults to no wrapping)--no-autolinks(Rust CLI defaults to no autolinks)--no-extract-metadata(Rust CLI defaults to no metadata extraction)
These flags can be safely removed from your commands, or you can leave them for compatibility.
Note: The Rust CLI only supports positive flags (e.g., --escape-asterisks, --autolinks, --wrap). Negative flags (--no-*) are only supported through the Python CLI proxy for v1 compatibility.
CommonMark-Compliant List Formatting
Section titled “CommonMark-Compliant List Formatting”- Tight lists no longer have blank lines before nested sublists - This follows the CommonMark specification for list formatting
- Previous behavior (v1):
* Item 1\n\n + Nested\n - New behavior (v2):
* Item 1\n + Nested\n - Why: CommonMark specifies that tight lists (lists without blank lines between items) should not have blank lines before nested sublists
- Impact: Generated markdown will render identically in CommonMark-compliant renderers but may look different in source form
- Migration: If you need the old behavior for specific platforms, you can post-process the output or use loose lists (with blank lines between items)
Core Rust Implementation
Section titled “Core Rust Implementation”- Complete Rust rewrite of HTML-to-Markdown conversion engine using
scraperandhtml5ever - Native Rust CLI with improved argument parsing and validation
- PyO3 Python bindings for seamless Rust/Python integration
- Automatic hOCR table extraction with built-in heuristics for OCR documents
New V2 API
Section titled “New V2 API”- Clean, modern API with dataclass-based configuration
convert(html, options, preprocessing)- primary API entry pointConversionOptions- comprehensive conversion settings (now includesencoding)PreprocessingOptions- HTML cleaning configuration- Legacy parsing options removed in favour of explicit encoding on
ConversionOptions - Improved type safety with full type stubs (
.pyifiles)
V1 Compatibility Layer
Section titled “V1 Compatibility Layer”- 100% backward compatible v1 API through compatibility layer
convert_to_markdown()function with all v1 kwargs- Smart translation of v1 options to v2 dataclasses
- CLI argument translation for v1 flags
- Clear error messages for unsupported v1 features
Testing & Quality
Section titled “Testing & Quality”- 77 new tests for v1 compatibility (32 bindings + 26 CLI + 19 integration)
- Comprehensive integration tests with actual CLI execution
- Wheel testing workflow for cross-platform validation
- Python 3.10, 3.12, 3.14-dev test matrix
- Dual coverage reporting (Python + Rust)
CI/CD Improvements
Section titled “CI/CD Improvements”- Shared build-wheels action for consistent wheel building
- Test-wheels workflow with full test suite on built wheels
- Rust coverage with
cargo-llvm-cov - Python coverage in LCOV format
- Automated wheel building for Python 3.10-3.13
Changed
Section titled “Changed”Performance
Section titled “Performance”- 60-80x faster than v1 for most conversion operations (144-208 MB/s throughput)
- Memory-efficient processing with Rust’s zero-cost abstractions
- Optimized table handling with rowspan/colspan tracking
- Faster list processing with unified helpers
Architecture
Section titled “Architecture”- Removed Python implementation (
converters.py,processing.py,preprocessor.py) - Migrated to Rust-based conversion engine
- Simplified Python layer to thin wrapper around Rust bindings
- CLI now proxies to native Rust binary with argument translation
API Design
Section titled “API Design”- More explicit configuration with separate option classes
- Better separation of concerns (conversion/preprocessing/parsing)
- Clearer parameter naming and organization
- Improved error messages and exception handling
Removed v1 Features
Section titled “Removed v1 Features”The following v1 features were removed in v2:
code_language_callback- Removed (usecode_languageoption for default language)stripoption - Removed (use preprocessing options instead)convertoption - Removed (all supported tags are converted by default)convert_to_markdown_stream()- Removed (html5ever does not support streaming parsing)
Not Yet Implemented
Section titled “Not Yet Implemented”custom_converters- Planned for future release with Rust and Python callback support
Migration Guide
Section titled “Migration Guide”For Most Users (No Changes Needed)
Section titled “For Most Users (No Changes Needed)”If you’re using the v1 API, your code will continue to work:
from html_to_markdown import convert_to_markdown
# This still works in v2!markdown = convert_to_markdown(html, heading_style="atx")To Use New V2 API (Recommended)
Section titled “To Use New V2 API (Recommended)”from html_to_markdown import convert, ConversionOptions
options = ConversionOptions(heading_style="atx")markdown = convert(html, options)CLI Changes
Section titled “CLI Changes”V1 CLI flags are automatically translated to v2:
# V1 style (still works)html-to-markdown --preprocess-html --escape-asterisks input.html
# V2 style (recommended)html-to-markdown --preprocess input.html # escaping is defaultPerformance Benchmarks
Section titled “Performance Benchmarks”Real-world performance improvements over v1 (Apple M4):
| Document Type | Size | V2 Latency | V2 Throughput | Speedup vs V1 (2.5 MB/s) |
|---|---|---|---|---|
| Lists (Timeline) | 129KB | 0.62ms | 208 MB/s | 83x |
| Tables (Countries) | 360KB | 2.02ms | 178 MB/s | 71x |
| Mixed (Python wiki) | 656KB | 4.56ms | 144 MB/s | 58x |
V2’s Rust engine delivers 60-80x higher throughput than V1’s Python/BeautifulSoup implementation across real-world documents.
Technical Details
Section titled “Technical Details”Rust Crates Structure
Section titled “Rust Crates Structure”crates/├── html-to-markdown/ # Core conversion library├── html-to-markdown-py/ # Python bindings (PyO3)└── html-to-markdown-cli/ # Native CLI binaryPython Package Structure
Section titled “Python Package Structure”html_to_markdown/├── api.py # V2 API├── options.py # V2 configuration dataclasses├── v1_compat.py # V1 compatibility layer├── cli_proxy.py # CLI argument translation├── _rust.pyi # Rust binding type stubs└── __init__.py # Public API exportsBreaking Changes Summary
Section titled “Breaking Changes Summary”None if using v1 compatibility layer. If migrating to v2 API:
- Import changes:
convert_to_markdown→convert - Configuration: Kwargs → Dataclasses (
ConversionOptions) - Defaults changed: See CommonMark-compliant defaults table above
- Removed features: See Removed v1 Features section above
Complete V1 vs V2 Comparison
Section titled “Complete V1 vs V2 Comparison”API Differences
Section titled “API Differences”| Aspect | V1 | V2 |
|---|---|---|
| Primary API | convert_to_markdown(**kwargs) |
convert(html, options, preprocessing, parsing) |
| Configuration | Keyword arguments | Dataclasses (ConversionOptions, etc.) |
| Type Safety | Basic type hints | Full .pyi stubs + generics |
| Compatibility Layer | N/A | convert_to_markdown() with v1 kwargs |
Performance Differences
Section titled “Performance Differences”| Document Type | V1 Throughput | V2 Throughput | Speedup |
|---|---|---|---|
| Lists (Timeline) | 2.5 MB/s | 208 MB/s | 83x |
| Tables (Countries) | 2.5 MB/s | 178 MB/s | 71x |
| Mixed (Python wiki) | 2.5 MB/s | 144 MB/s | 58x |
| Average | 2.5 MB/s | 177 MB/s | 71x |
Implementation Differences
Section titled “Implementation Differences”| Component | V1 | V2 |
|---|---|---|
| HTML Parser | BeautifulSoup4 / lxml | html5ever (Rust) |
| Sanitizer | Custom Python | html5ever DOM filtering |
| Conversion | Pure Python (~3,850 lines) | Pure Rust (~4,800 lines) |
| Bindings | N/A | PyO3 |
| CLI | Python wrapper | Native Rust binary |
| Dependencies | bs4, lxml, soupsieve | None (statically linked) |
Output Differences (Default Settings)
Section titled “Output Differences (Default Settings)”| HTML | V1 Output | V2 Output |
|---|---|---|
<ul><li>Item</li></ul> |
* Item (4 spaces) |
- Item (2 spaces) |
<h1>Title</h1> |
Title\n===== |
# Title |
Text*with*stars |
Text\*with\*stars |
Text*with*stars |
<br> |
Two trailing spaces | Backslash \ |
<pre>code</pre> |
```\ncode\n``` |
Indented 4 spaces |
These differences reflect v2’s alignment with CommonMark specification.
Removed Python Implementation
Section titled “Removed Python Implementation”- Python implementation of HTML conversion
html_to_markdown/converters.py(1220 lines)html_to_markdown/processing.py(1195 lines)html_to_markdown/preprocessor.py(404 lines)html_to_markdown/whitespace.py(293 lines)html_to_markdown/utils.py(37 lines)- Several test files migrated to Rust or marked as
.skip
Total: ~3,850 lines of Python code removed, replaced by ~4,800 lines of Rust
- Platform Support: Wheels built for Linux, macOS, Windows on x86_64
- Python Version: Requires Python 3.10+
- ABI Compatibility: Uses
abi3for Python 3.10+ wheel reuse - Rust Version: Built with stable Rust (tested on 1.75+)
[1.x] - Previous Versions
Section titled “[1.x] - Previous Versions”For changes in v1.x releases, see git history before the v2 rewrite.