ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.