Turndown HTML to Markdown with custom rules: working versions (tested 2026-10-15)
Convert HTML to clean Markdown with Turndown using ATX headings, fenced code blocks with languages, a strikethrough rule, a figure rule and removal of script and style tags.
passed3 passed · 0 failed2026-10-15 17:11 UTC- Status
- passed
- Last passed
- 2026-10-15 17:11 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions4
turndown@7.2.4@types/node@26.6.5tsx@4.23.15@types/turndown@5.0.6
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38775053802
The code
Exactly the files that passed. Your agent can pull them with get_recipe("turndown-html-to-markdown") (install), or see them on GitHub.
package.json
{
"name": "turndown-html-to-markdown",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"turndown": "7.2.4"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"@types/turndown": "5.0.6"
}
}
src/convert.ts
import TurndownService from "turndown";
export function createConverter() {
const td = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "_",
});
td.remove(["script", "style"]);
td.addRule("strikethrough", {
filter: ["del", "s"],
replacement: (content) => `~~${content}~~`,
});
// <figure><img><figcaption> becomes an image whose alt text is the caption.
td.addRule("figure", {
filter: "figure",
replacement: (_content, node) => {
const el = node as HTMLElement;
const img = el.querySelector("img");
const caption = el.querySelector("figcaption")?.textContent?.trim() ?? img?.getAttribute("alt") ?? "";
return img ? `\n\n})\n\n` : "";
},
});
return td;
}
const converter = createConverter();
export function htmlToMarkdown(html: string): string {
return converter.turndown(html);
}
test/convert.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { htmlToMarkdown } from "../src/convert.ts";
test("headings, emphasis, links and lists", () => {
const md = htmlToMarkdown('<h1>Title</h1><h2>Sub</h2><p>Some <strong>bold</strong> and <em>soft</em> <a href="https://example.com">link</a>.</p><ul><li>one</li><li>two</li></ul>');
assert.match(md, /^# Title$/m);
assert.match(md, /^## Sub$/m);
assert.match(md, /\*\*bold\*\*/);
assert.match(md, /_soft_/);
assert.match(md, /\[link\]\(https:\/\/example\.com\)/);
assert.match(md, /^-\s+one$/m);
assert.match(md, /^-\s+two$/m);
});
test("fenced code keeps the language from class names", () => {
const md = htmlToMarkdown('<pre><code class="language-ts">const x = 1;\n</code></pre>');
assert.equal(md, "```ts\nconst x = 1;\n```");
});
test("custom rules: strikethrough and figure; scripts removed", () => {
const md = htmlToMarkdown('<p><del>old</del> new</p><script>evil()</script><figure><img src="/a.png" alt="alt"><figcaption>A chart</figcaption></figure>');
assert.match(md, /~~old~~ new/);
assert.match(md, /!\[A chart\]\(\/a\.png\)/);
assert.doesNotMatch(md, /evil/);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.