Porting htomd beyond Python

Porting htomd to TypeScript, Go and Rust, and building Shipwright to keep them in sync.

Jamie Davenport

Earlier this week I released htomd, a small Python library for extracting Markdown and metadata from HTML. I wanted clean page content to pass into LLMs, without navigation, newsletter forms and other clutter taking up context.

You can now use htomd in Python, TypeScript, Go and Rust. Each implementation runs in its own language, with the same extraction pipeline and shared tests. You supply the HTML; it returns the useful content.

import { convert } from "@jamiedavenport/htomd";

const url =
  "https://tailwindcss.com/blog/" +
  "tailwind-is-joining-shopify";

const response = await fetch(url);
if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const html = await response.text();
const markdown = convert(html);

console.log(markdown);

Markdown

![Tailwind CSS and Shopify](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fog-image.103kpqahmns2b.jpg&w=3840&q=75)

Big one today — Tailwind is joining Shopify.

When I started working on Tailwind over nine years ago, my only goal was to create something that would make it easier to build beautiful interfaces for my own projects. Fast-forward to today and the framework is installed over 110 million times per week and is trusted by many of the world's biggest companies to style products like ChatGPT, X, Cloudflare, Reddit, and Shopify.

We're joining Shopify to give Tailwind a stable long-term home where it will be actively maintained for the millions of people who depend on it.

## [Why Shopify](#why-shopify)

We built a great little website template business around Tailwind over the years, but deep down I've always wanted the framework to be developed in service of a real product. A complex application solving important problems for real people, where we'd face the same challenges as our users, and could invent solutions that make the framework better for everyone.

**Shopify provides an incredible surface area for us to do this work.** Merchants need to be able to design and host beautiful custom storefronts, and manage sales and inventory in a powerful admin area. Their customers need delightful shopping and checkout experiences, and an intuitive way to keep track of their orders and discover new products through the Shop app. Shopify is also on the frontier of where user interfaces need to go next with their explorations into agentic commerce.

**Shopify was also one of the very first companies operating at scale to see the potential in Tailwind CSS and start building with it**, not only for themselves but [betting on it for their customers](https://x.com/ShopifyDevs/status/1457723173047263234) too. Tailwind is a ~~load-bearing~~ very important part of the stack at Shopify, and they're invested in making sure it's actively maintained and continues to improve and adapt for how the ways we build are changing.

On a less technical note, I'm personally excited because **entrepreneurship has completely changed my life**. We are not doing enough as a society to produce and empower more entrepreneurs, and I believe deeply in Shopify's mission to help more people start, run, and grow their own business.

## [What's next](#whats-next)

**Nothing changes with Tailwind CSS or any of our other open-source projects**. Everything will always be MIT-licensed, and our team will continue to lead and maintain these projects for the community with the support of Shopify.

On the commercial side, we'll no longer be trying to grow the business around Tailwind. All existing customers will of course maintain their access to products like Tailwind Plus and [ui.sh](http://ui.sh/), but we're closing sign ups for new customers to focus on Tailwind CSS at Shopify.

Thank you so much to everyone who has built something with Tailwind and supported us over these last nine years. I never could've imagined the project would become what it has today, and I truly believe there's no better place for us to continue to do this work than Shopify.

htomd keeps the article’s text, formatting, links and hero image, leaving the navigation and newsletter behind. extract() also returns metadata and diagnostics.

The work also gave me a reason to start Shipwright, a set of agent skills and tools for creating and maintaining language ports. htomd is the first project I’m using it on.

Performance

You’ll probably use the version that matches the rest of your project. That’s the main reason for the ports. Performance varies, but all four handled these 115 saved pages in around 1.3 seconds or less on an Apple M5 Max.

  • TypeScript

    0.319 seconds

  • Rust

    0.327 seconds

  • Go

    0.729 seconds

  • Python

    1.271 seconds

One reference

Python is the behavioural reference. I like it as a source language because it reads close to pseudocode: the steps are easy to follow, and there’s little syntax getting in the way. The reference is also executable, so the ports can be checked against it.

Before creating a port, the agent uses Shipwright’s sw-explore skill to read the implementation, public APIs, tests and tooling. It maps what callers can observe and identifies where language differences might affect the result.

The target skill then guides the implementation: sw-ts, sw-go or sw-rust. These cover types, error handling, dependencies, packaging and language-specific pitfalls. The instructions are small enough to read directly.

All four implementations follow the same pipeline: parse HTML, read metadata, select useful content, refine the metadata and render Markdown. Their internal structures can differ.

Native code

Here’s conditional_clutter, which decides whether a block can be removed. A class name such as sidebar isn’t enough: it also looks for evidence such as a high proportion of linked text or form controls.

Python

def conditional_clutter(node: Node, stat: Stats) -> bool:
    if node.tag in {"#document", "html", "body", "main", "article"}:
        return False
    tokens = hints(node)
    if tokens & REFERENCES or node.attrs.get("role") == "note":
        return False
    if not tokens & NEGATIVE or node.tag in EVIDENCE | HEADINGS:
        return False
    # Class hints alone never delete a node: require structural corroboration.
    if stat.density > 0.35 or stat.controls:
        return True
    if "footer" in tokens and stat.density > 0.15:
        return True
    if node.tag == "aside" and not stat.code and not stat.cells:
        return True
    if stat.blocks == 0 and stat.characters < 180:
        return True
    if tokens & {"comments", "comment"}:
        children = elements(node)
        return sum(bool(hints(child) & {"comment", "reply"}) for child in children) >= 2
    return False
function conditionalClutter(node: Node, stat: Stats): boolean {
  if (["#document", "html", "body", "main", "article"].includes(node.tag)) {
    return false;
  }
  const tokens = hints(node);
  if (intersects(tokens, REFERENCES) || node.attrs.get("role") === "note") {
    return false;
  }
  if (!intersects(tokens, NEGATIVE) || EVIDENCE.has(node.tag) || HEADINGS.has(node.tag)) {
    return false;
  }
  if (stat.density > 0.35 || stat.controls) {
    return true;
  }
  if (tokens.has("footer") && stat.density > 0.15) {
    return true;
  }
  if (node.tag === "aside" && !stat.code && !stat.cells) {
    return true;
  }
  if (!stat.blocks && stat.characters < 180) {
    return true;
  }
  if (tokens.has("comments") || tokens.has("comment")) {
    return (
      elements(node).filter((child) => {
        const childHints = hints(child);
        return childHints.has("comment") || childHints.has("reply");
      }).length >= 2
    );
  }
  return false;
}

Each port should feel natural to someone who writes that language. The goal is to preserve what the library does, with each implementation free to choose its own data structures, control flow and dependencies.

The awkward bits

Some language differences are inevitable, for example in URL handling and parsing. We keep those native behaviours and record accepted differences in the conformance fixtures, with an explanation and expected output for each.

The goal is still to match the package’s overall I/O behaviour. Shared tests enforce that contract, and unexpected differences fail.

Shipwright

This is what ties the work together. A small configuration identifies the reference and its targets:

source = "python/src/htomd"
targets = ["typescript", "go", "rust"]

One shipwright build command builds every package concurrently, using each language’s existing tools. For htomd, that’s uv build, bun run build, go build and cargo build --release.

shipwright build
shipwright test
shipwright lint
shipwright format
shipwright release

The test, lint and format commands follow the same pattern, running each package’s native tools and reporting failures per package. shipwright release --dry-run prepares the packages for review; shipwright release publishes them to their respective registries.

There’s much more I want Shipwright to do: project setup, shared conformance checks, and potentially a dependency-mapping MCP server to help agents find suitable libraries in each language.

It’s still early days, but if this works, a library’s original language could stop being a barrier to using it.

Keeping up

The next test is maintaining these ports as htomd changes. Behaviour changes start in Python, then the agent can update each target and run the shared checks. Language-specific improvements need to survive those updates too.

I’d like Shipwright to make that routine for other libraries. htomd gives me a concrete place to work out what that requires.

The htomd source includes all four implementations, the skills and the tests. Shipwright is early, but you can read and use the build and release tooling today.