unified Markdown to safe HTML with remark-gfm and rehype-sanitize: working versions (tested 2026-10-15)
Turn Markdown into sanitized HTML with unified, remark-parse, remark-gfm, remark-rehype, rehype-sanitize and rehype-stringify, plus a custom plugin that collects a table of contents.
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 versions9
unified@11.0.5remark-parse@11.0.0remark-gfm@4.0.1remark-rehype@11.1.2rehype-sanitize@6.0.0rehype-stringify@10.0.1unist-util-visit@5.1.0@types/node@26.6.5tsx@4.23.15
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("unified-remark-rehype-pipeline") (install), or see them on GitHub.
package.json
{
"name": "unified-remark-rehype-pipeline",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"unified": "11.0.5",
"remark-parse": "11.0.0",
"remark-gfm": "4.0.1",
"remark-rehype": "11.1.2",
"rehype-sanitize": "6.0.0",
"rehype-stringify": "10.0.1",
"unist-util-visit": "5.1.0"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
src/markdown.ts
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
import { visit } from "unist-util-visit";
export type TocEntry = { depth: number; text: string };
type MdNode = { type: string; depth?: number; value?: string; children?: MdNode[] };
function textOf(node: MdNode): string {
if (typeof node.value === "string") return node.value;
return (node.children ?? []).map(textOf).join("");
}
/** remark plugin: stores the heading outline on file.data.toc */
export function remarkToc() {
return (tree: MdNode, file: { data: Record<string, unknown> }) => {
const toc: TocEntry[] = [];
visit(tree as any, "heading", (node: any) => {
toc.push({ depth: node.depth, text: textOf(node) });
});
file.data.toc = toc;
};
}
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkToc)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify);
export async function renderMarkdown(md: string): Promise<{ html: string; toc: TocEntry[] }> {
const file = await processor.process(md);
return { html: String(file), toc: (file.data.toc as TocEntry[]) ?? [] };
}
test/markdown.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderMarkdown } from "../src/markdown.ts";
test("renders GFM tables, strikethrough and task lists", async () => {
const { html } = await renderMarkdown([
"| a | b |", "| - | - |", "| 1 | 2 |", "", "~~old~~ new", "", "- [x] done", "- [ ] todo",
].join("\n"));
assert.match(html, /<table>/);
assert.match(html, /<td>1<\/td>/);
assert.match(html, /<del>old<\/del>/);
assert.match(html, /<input type="checkbox" checked disabled>/);
});
test("drops raw HTML and javascript: links", async () => {
const { html } = await renderMarkdown('<script>alert(1)</script>\n\n[click](javascript:alert(1)) and [ok](https://example.com)');
assert.doesNotMatch(html, /<script/);
assert.doesNotMatch(html, /javascript:/);
assert.match(html, /<a href="https:\/\/example.com">ok<\/a>/);
});
test("collects a table of contents from headings", async () => {
const { toc, html } = await renderMarkdown("# Intro\n\nText\n\n## Install *fast*\n\n### Notes");
assert.deepEqual(toc, [
{ depth: 1, text: "Intro" },
{ depth: 2, text: "Install fast" },
{ depth: 3, text: "Notes" },
]);
assert.match(html, /<h2>Install <em>fast<\/em><\/h2>/);
});
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.