TransformPipe

السجل الرسمي الإنتاجية

Convert documents to Markdown and back, keep them, and share them as a page with one link.

ماذا يمكنك أن تفعل مع TransformPipe؟

  • Upload — drag & drop or file picker, .md / .markdown / .mdown / .mkd / .txt, up to 10 MB.
  • Chain several files — drop more than one and they become a single document, in the order they
  • Preview — GitHub Flavored Markdown (tables, task lists, strikethrough,
  • Share — a document in your account can be opened by anyone with the link, or only by the
  • Shared with me — a chip in the history lists documents other people addressed to you, with
  • Fullscreen reading — the preview takes the whole screen, the sheet keeps a readable measure
  • HTML source tab — the exact standalone document that gets downloaded.
  • Download / Copy — self-contained .html with inline styles, print-ready. Nothing is fetched

التثبيت

أضف هذا إلى إعدادات MCP في عميلك (Claude Desktop: claude_desktop_config.json، وCursor: ‏.cursor/mcp.json، ويستخدم VS Code وغيره الصيغة نفسها «mcpServers»).

Remote (streamable-http)
Claude Code
claude mcp add --transport http transformpipe 'https://transformpipe.com/api/mcp'
إعداد JSON (Claude Desktop وCursor وVS Code)
{
    "mcpServers": {
        "transformpipe": {
            "type": "http",
            "url": "https://transformpipe.com/api/mcp"
        }
    }
}

التوثيق

مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.

المحتويات
  1. TransformPipe
  2. Twenty conversions, in your browser
  3. Markdown in, a finished page out
  4. Send a link, not an attachment
  5. What your assistant writes, kept
  6. One library, not forty chats
  7. Any web page, as Markdown
  8. Your documents, your account
  9. Every way in
  10. Conversions
  11. Run locally
  12. Configuration
  13. What it does
  14. UI
  15. API
  16. Webhooks
  17. From a terminal
  18. As a GitHub Action
  19. In an assistant
  20. Limits
  21. Abuse
  22. Blog and documentation
  23. Addresses
  24. Where a document lives
  25. Rendering
  26. Structure
  27. Licence

TransformPipe

Turn a Word file, a web page or a whole Notion export into a clean document, keep it, and send it as a page with one link.

**Open it** · Documentation · Browser extension · What has shipped · Support

Documents arrive in the wrong shape. A Word file you need as Markdown. A Notion export that is forty pages in a zip. A spec your assistant wrote, gone once the chat scrolls away. Markdown pasted into an email, landing as a wall of asterisks.

TransformPipe turns all of them into one thing: a clean Markdown document. See it as a finished page, take it away as Markdown, HTML, Word, PDF, Confluence, Jira, Slack or EPUB, or keep it and send a link instead of an attachment. Open transformpipe.com and drop a file. No account is needed to convert.

Twenty conversions, in your browser

Word, PowerPoint, Excel, EPUB, OpenDocument, rich text, HTML, CSV, JSON, plain text, Evernote, or a whole Notion, Confluence or Obsidian export. Each comes back as Markdown with its headings, lists, links and tables intact, and Markdown goes the other way: into a finished page, a Word document, a Confluence page, a Jira ticket, a Slack message or an EPUB book.

The conversion runs on your machine. Signed out, nothing is uploaded and nothing needs a network. Drop several files and they become one document, in the order you picked them.

Markdown in, a finished page out

Tables, code, task lists, maths and Mermaid diagrams come out the way they were meant to look. The download is one self-contained .html file that fetches nothing when it opens: no scripts and no webfonts. A saved document also downloads as Word, and any document prints to PDF.

Share a saved document with anyone who has the link, or only with the addresses you name. Give the link a password or an end date if it needs one, and see how often it was opened. Make private stops it in one step, and the old link stops working for good.

Publish new text from the API, the command line, an assistant or Obsidian, and the link people already have shows it. The text it replaced is kept as a revision.

What your assistant writes, kept

TransformPipe is in Claude's connector directory: press Connect, sign in, and ask in plain words. "Save this as the Q3 plan." "Publish it and give me the link." "Find the migration spec from last week." The document goes into your account, with a title you can search for, long after the chat has scrolled away.

ChatGPT connects too, by address, as an MCP app, and Obsidian has a community plugin. All three see the same documents.

One library, not forty chats

Everything you convert, save or have an assistant save lands in one list: searchable, filtered by format, with versions side by side and a line-level diff between them. Signed in, it is the same list on every device.

Any web page, as Markdown

The browser extension, for Chrome and Firefox, takes the page you are on and hands back the article without the navigation or the cookie notice. Copy it, download it, or save the page as a self-contained .html file. Signed out, it never talks to us.

Your documents, your account

An assistant acts as you, on your documents only. It cannot change the account, see your password or create API keys, and it deletes nothing without an explicit confirmation. A grant can be read-only. The service is free, and the limits are written down: 5,000 documents and 250 MB an account, 4 MB a document. Reaching one refuses the save; nothing is deleted to make room.

Every way in


Conversions

Live at transformpipe.com. The old md-2-html.vercel.app still answers, so links already shared keep working; the canonical URLs, the sitemap and every default in the CLI and the Action name the new domain. Nothing in server/ carries a domain at all — origins come from the request through selfOrigin, which is what made the move a matter of one trusted origin and a rebuild.

Upload a file, see exactly what it became, and download it. Twenty conversions, each with its own page and address:

ConversionTakesProduces
Markdown → HTML.md .markdown .mdown .mkda self-contained .html
Markdown → Confluence.mdConfluence storage format (code macros, panels, task lists)
Markdown → Jira.mdJira wiki markup
Markdown → Slack.mdSlack mrkdwn (tables as aligned code blocks)
Markdown → EPUB.mdan .epub book, a chapter per top heading
Markdown → Word.mda .docx in Word's own styles (Heading 1–6, lists, footnotes)
HTML → Markdown.html .htm .xhtml.md
Raw text → Markdown.txt.md
CSV → Markdown table.csv .tsv.md
JSON → Markdown.json.md
Notion export → Markdown.zip (Export as Markdown & CSV).md
Confluence export → Markdown.zip (Export → HTML).md
Obsidian vault → Markdown.zip (the vault folder, zipped).md
Word → Markdown.docx.md
Excel → Markdown table.xlsx.md
PowerPoint → Markdown.pptx.md (a section per slide, speaker notes kept)
EPUB → Markdown.epub.md (a section per chapter, in reading order)
OpenDocument → Markdown.odt.md
Rich text → Markdown.rtf.md
Evernote → Markdown.enex.md (a section per note, tags kept)

They all normalise to Markdown, which is what a document is stored, previewed, shared and reached by a script as — one shape rather than fourteen. Any document can then be handed over as Markdown, HTML, plain text, Word, Confluence, Jira, Slack, EPUB, or printed to PDF. shared/conversions.ts is the single list; the header menu, the screens, the history chips, the badges and the prerendered pages all read it, so a new conversion is an entry there plus a converter.

.txt used to be accepted on the Markdown → HTML page as if it already were Markdown; it has its own conversion now instead, because a plain-text file is not Markdown even when it looks like it — an asterisk typed as a literal asterisk and one meant as emphasis are the same character, and only one of the two conversions is supposed to treat them alike.

Notion, Confluence and Obsidian are the three that do not produce one document from one file: each export is several pages (or notes) in a .zip, and each comes back as a single Markdown document — a table of contents, then every page in order, each a heading of its own (shared/from-notion.ts, shared/from-confluence.ts, shared/from-obsidian.ts, sharing the zip-reading and merge convention in shared/zip-import.ts). A link from one page to another inside the export keeps its words and drops its address — Obsidian's own [[wikilinks]] included: once every page is a section of one document, there is nowhere left for it to point.

Conversion happens in the browser — the .docx reader and the HTML parser load only when their page is used, so the front page's bundle does not carry them. Sign in — with Google, or an email and a password — to keep your documents in the account and reach them from any device.

Run locally

npm install
cp .env.example .env.local   # then fill in the values (see Configuration)
npm run db:init              # creates the table in Neon
npm run dev

The app starts on http://127.0.0.1:5180 — /api/* is served by the same Hono app that runs as a Vercel function in production, so no extra process is needed.

Other scripts: npm run build, npm run preview, npm run check-types, npm run db:init.

Without NEON_AUTH_BASE_URL the app still converts files: sign-in answers 503 and the history falls back to this browser's localStorage.

Configuration

VariableNotes
DATABASE_URLNeon pooled connection string
NEON_AUTH_BASE_URLthe project's Neon Auth endpoint

Both are server-side only; the browser talks to /api/* and never to Neon directly.

Sign-in is Neon Auth (Better Auth behind a Neon endpoint), which already carries Google — no separate Google OAuth client and no client secret here. /api/auth/* forwards to the auth service and rewrites Set-Cookie so the session cookie is first-party for this site; /api/auth/finish exchanges the one-time verifier for that cookie. See server/auth.ts.

Neon Auth only starts a sign-in for an origin it trusts, so each origin has to be added once — and this is the whole of what breaks when the site moves to a new domain: everything else derives its URLs from the request, and sign-in is the one thing that has to be told:

npm run auth:origin                                # list what is trusted
npm run auth:origin -- https://transformpipe.com   # the live site
npm run auth:origin -- http://127.0.0.1:5180       # for local work

Neon — TransformPipe has its own Neon project (tp): its own database and its own Neon Auth, with no accounts or tables shared with any other app. It was provisioned through the Vercel Marketplace, which also connects it and writes DATABASE_URL / NEON_AUTH_BASE_URL into the project:

vercel integration add neon --name m2h
vercel env pull .env.local --environment=production
npm run env:setup          # prunes .env.local down to this app's two keys
npm run db:init            # creates m2h_document

Vercel — the variables above are set for production, preview and development by that connect step; a deployment made before them needs a redeploy to see them. The build is auto-detected (Vite → dist) and /api/* is routed to the Hono function by vercel.json.

What it does

  • Upload — drag & drop or file picker, .md / .markdown / .mdown / .mkd / .txt, up to 10 MB.
  • Chain several files — drop more than one and they become a single document, in the order they arrive, separated by a rule; the same works on any selection in the history.
  • Preview — GitHub Flavored Markdown (tables, task lists, strikethrough, autolinks), sanitized with DOMPurify, styled with the design-system tokens.
  • Share — a document in your account can be opened by anyone with the link, or only by the addresses you list (they sign in with that address). /s/<token> is a read-only page: the document and a download, nothing else. Revoking drops the token, so a link already sent stops working. Each address you list is emailed the link once.
  • Shared with me — a chip in the history lists documents other people addressed to you, with who shared each one. They are read-only: open and download, no delete, no re-share. Only addressed shares appear; a link share belongs to whoever holds the link, not to a list.
  • Fullscreen reading — the preview takes the whole screen, the sheet keeps a readable measure and scrolls; Escape comes back. A long document gets a back-to-top button, in both the normal and the fullscreen view.
  • HTML source tab — the exact standalone document that gets downloaded.
  • Download / Copy — self-contained .html with inline styles, print-ready. Nothing is fetched when it opens: no scripts, and no webfont either. It used to link DM Sans from Google, which made "self-contained" false in the one file that is actually handed to someone else, so the export falls back through ui-sans-serif to the system face and asks for nothing.
  • Download as Word — a saved document's download menu gets a .docx, built server-side from the same HTML the preview renders (@turbodocx/html-to-docx — pure JavaScript, no headless browser). Needs a save first: the endpoint reads the account's copy, not the browser's.
  • A .pdf from the API — GET /api/v1/documents/:id.pdf, laid out by pdfmake from the same HTML rather than a headless Chrome — no button for it in the app, deliberately: "Print or save as PDF" already gives a signed-in person the browser's own, pixel-exact PDF, and this is for the case that button cannot reach — a script, a CI job, a GitHub Action step with no browser at all.
  • History — signed in: stored in the account (up to 5,000 documents, 4 MB of source each), available on every device; signed out: the last 25 conversions in localStorage. Whatever was collected locally is moved into the account on first sign-in. The page has its own dropzone, a search box over file names, Markdown/HTML chips and sortable headers; a row opens the document, and rows can be selected in bulk to merge, download or delete them (the selection bar and the chips come from the design system).

    The chips choose which face of a document the list shows: the Markdown source it was made from, or the HTML it converts to — the row's name, its type badge and what a download hands over all follow. Only the source is stored; the HTML is built on the spot, which is why the size column names what it measures.

  • Versions — pushing with ?replaces=<id> (API), --replaces (CLI) or replaces (Action) links a document to an earlier one as a new version of it — opt-in and explicit, never inferred, so a new push stays the unrelated document it has always been unless told otherwise. Linked documents get a chain icon in the history and a line-level diff against the version before them.
  • Sign-in — Google, or an email and a password, through Neon Auth.
  • Summary — a saved document gets a third tab: a paragraph on what it is and where it lands, then its specifics as a short list, from Gemini, streamed into the tab as it is written and called directly with a Google AI Studio key (GOOGLE_GENERATIVE_AI_API_KEY — free tier, no card needed). Generated once and cached on the row, so opening the tab again is free; "Regenerate" asks again. Metered separately from the ordinary API limits — 20 a day per account — because unlike the rest of this app, it costs money per call. Without a key the tab says so rather than failing the save.

UI

The interface is built on our internal design system: design tokens (light and dark), DM Sans, and the React component library are vendored under src/ui, extended by tailwind.ui.config.ts / tailwind.config.ts and loaded through src/index.css. The app defaults to the dark theme (<html class="dark">); switching to light is a matter of dropping that class.

Markdown document styling (src/lib/md-doc-css.ts) is written once against --md-* variables and used both in the preview and in the exported file, so the two always match, down to the sheet's own background — the document follows whichever theme the app is in, and the downloaded file carries that palette with it. Printing always flips to the light values, because a dark page on paper is a wall of ink.

The theme (dark by default) lives behind the account menu, remembered per browser in localStorage; signed out, a sun/moon button in the header does the same job.

API

Everything the app does, a script can do with a key: account menu → API keys. The key is shown once, stored only as a hash, and can be revoked at any time. It reaches documents and shares — never the account or the keys themselves, so a leaked key cannot mint its replacement or lock you out.

# publish a file in one request
curl -H "Authorization: Bearer tp_live_…"      --data-binary @README.md      "https://transformpipe.com/api/v1/documents?name=README.md&share=link"
# → { "document": { "id": "…", "share": { "mode": "link", "url": "https://…/s/…" } } }
POST /api/v1/documentsMarkdown as the body (?name=) or JSON {name, markdown}; `?share=link\peoplepublishes it in the same call, and?expires_at=gives that link an end;?kind=html-to-markdown\csv-to-markdown\json-to-markdown\word-to-markdown\notion-to-markdown\confluence-to-markdown\obsidian-to-markdown\text-to-markdown\excel-to-markdown\powerpoint-to-markdown\epub-to-markdown\odt-to-markdown\rtf-to-markdown\evernote-to-markdownconverts the body first — for every format that is not plain text, post the file itself (.docx,.zip,.xlsx,.pptx,.epub,.odtor.rtf) as the body;?replaces=<id>` links it to an earlier document as a new version, opt-in
GET /api/v1/documentsthe newest 500; ?q= searches content as well as name, ranked by relevance
GET /api/v1/documents/:idmetadata and the source
GET /api/v1/documents/:id.htmlthe standalone document, ?theme=dark optional
GET /api/v1/documents/:id.docxa Word document, built from the same HTML on the way out
GET /api/v1/documents/:id.pdfa PDF, laid out from the same HTML by pdfmake — no headless browser
GET /api/v1/documents/:id/versionsevery document in the same version chain, oldest first
PUT /api/v1/documents/:idnew text for the same document — Markdown as the body, or JSON {markdown, name}. The id, the link and everything set on it stay; the text it replaces is kept as a revision (the newest ten, counted as bytes, not as documents). The same text again answers changed: false
GET /api/v1/documents/:id/revisionsthose revisions, newest first; /revisions/:revision reads one with its Markdown
DELETE /api/v1/documents/:idremoves the row, its source and its revisions
`GET \PUT /api/v1/documents/:id/share`{mode, emails[], expires_at}; private drops the token, so a link already sent stops working. expires_at is an ISO 8601 date-time the link stops working at (410 after it), null for no end, and left out to keep the one it has. The answer also carries views and last_viewed_at: opens of the link, counted by the shared page's own one-pixel picture. password sets one on a link, or ahead of one while the document is private (8 to 200 characters, in the body only), and null removes it; a share with specific people takes none; the answer says has_password, never the hash
GET /api/v1/documents/:id/viewsThe link's recent opens, newest first: {views, last_viewed_at, events: [{at, via}]} — via is page or app. A link keeps nothing about who opened it; a share addressed to people adds who to each open and a people list — each named address, its opens and its last one
POST /api/v1/documents/:id/summarya cached summary, generating it first if there is none; ?force=1 regenerates. Limited to 20 a day per account

A cookie works too, so the same endpoints can be tried from a signed-in browser. GET /api/v1/usage says what an account is using. Errors are { "error": "…" } with a status that means what it says: 401 unknown key, 404 not yours, 413 the document is over 4 MB, 403 the account is out of room, 429 too fast, 410 the source is gone.

Webhooks

account menu → Webhooks registers a URL that gets a signed POST when a document is created or shared with named people. Session-only, deliberately — unlike API keys, a webhook is not exposed under /api/v1: an API key that could also register one would turn a leaked key into a standing feed of every future document, rather than the point-in-time access it is today.

{
  "event": "document.created",
  "created_at": "2026-09-11T12:00:00.000Z",
  "data": { "id": "…", "name": "notes.md", "kind": "markdown-to-html", "size": 512 }
}

The body is signed with HMAC-SHA256 over {timestamp}.{body}, in the x-transformpipe-signature: t=<unix-seconds>,v1=<hex> header — the same shape Stripe and GitHub use, so existing verification code usually needs only the secret changed:

const expected = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

The secret is shown when the webhook is created and can be shown again from the dialog — unlike an API key, a webhook secret is presented by this app rather than to it, so the account owner may legitimately need it again to configure or debug a receiver. Delivery is best effort: one request, a five-second timeout, no retry and no queue — a receiver that is down misses that delivery, and the dialog shows when the last one failed.

From a terminal

cli/tp.mjs is the same API with a friendlier face. No dependencies — it is one fetch and some printing, because a tool people run in CI should not drag a package tree behind it.

node cli/tp.mjs login tp_live_…              # remembers the key in ~/.config/tp/config.json
node cli/tp.mjs push README.md --share        # prints the link
node cli/tp.mjs push docs/*.md --merge --share --name handbook.md
node cli/tp.mjs push notes.md --share --expires 7d    # a link that stops working in a week
TP_SHARE_PASSWORD=… node cli/tp.mjs push notes.md --share --password  # a link that asks for it
node cli/tp.mjs list
node cli/tp.mjs rm <id>
node cli/tp.mjs summary <id>                  # generated once, cached; --force to regenerate
node cli/tp.mjs push v2.md --replaces <id>    # links it to an earlier document as a new version
node cli/tp.mjs push notes.md --update <id>   # new text, same link; the old text is kept as a revision
node cli/tp.mjs versions <id>                 # every document in the chain, oldest first
node cli/tp.mjs usage                         # 65.8 kB of 250.0 MB · 3 of 5000 documents

node cli/tp.mjs folders                       # the tree: ids, documents in each, the link if shared
node cli/tp.mjs push notes.md --folder "Team/Notes"   # filed there; missing folders are made
node cli/tp.mjs list --folder "Team/Notes"    # that folder and everything under it; - for unfiled
node cli/tp.mjs mkdir "Team/Notes/2026"       # every part of the path not there yet
node cli/tp.mjs mv <id>… "Team/Archive"       # documents, or folders, into a folder; - for none
node cli/tp.mjs share-folder "Team/Notes" --link --expires 7d    # prints <host>/f/<token>
node cli/tp.mjs share-folder "Team/Notes" --people a@example.com,b@example.com
node cli/tp.mjs share-folder "Team/Notes" --private
node cli/tp.mjs rmdir "Team/Old"              # its documents move up a level
node cli/tp.mjs rmdir "Team/Old" --with-documents   # and deletes everything under it

The key comes from --key, TP_API_KEY, or that config file, in that order; TP_HOST points it at another deployment. --json prints the API's own response, which is what the Action reads. A folder is named by its id from tp folders or by its path — the names from the top, / between them, in any case.

As a GitHub Action

action.yml at the root of this repository publishes Markdown from a workflow. Given no file list it takes what the pull request changed, and with pull-requests: write it comments the links, so a reviewer opens the rendered document instead of reading a diff of asterisks.

- uses: raudarlabs/transformpipe@main
  with:
    api-key: ${{ secrets.TP_API_KEY }}

examples/publish-markdown.yml is a complete workflow to copy. Inputs: api-key, files, share (link / people / none), merge, name, replaces, comment, host; outputs: urls and documents. Checkout needs fetch-depth: 0 for the base commit the file list is computed against.

A new document per push is deliberate — a link in an old comment keeps showing what that commit said. share: none publishes privately if the links should not be public. replaces is the one way to say two pushes are versions of the same thing rather than unrelated documents — pass the previous push's id and the two show up linked, with a diff, in the account's history.

In an assistant

TransformPipe is an MCP server at /api/mcp, so it can be added to Claude as a connector, or to ChatGPT as an MCP app (Plugins → Add → Create MCP App, the address, then sign in), and convert, save, share, summarise, version and delete documents in one account. There is no key to paste:

claude mcp add --transport http transformpipe https://transformpipe.com/api/mcp

The first call is answered 401 with a WWW-Authenticate header naming /.well-known/oauth-protected-resource, the client follows that to /.well-known/oauth-authorization-server, identifies itself, and sends the person to /api/oauth/authorize. It identifies itself in one of two ways: with a Client ID Metadata Document — an https URL for a client_id, which server/cimd.ts fetches, checks and caches — or by registering (RFC 7591) and being given an id. Claude prefers the document and so does the MCP spec, because registration mints a fresh client on every connection; registration stays for clients that do not implement it. They sign in with the same Google account and approve a named client on a page they read — a POST, so a link on its own authorises nothing — and the client exchanges its code for a token of ours. server/oauth.ts is that authorization server and server/mcp.ts is the endpoint; src/lib/mcp-facts.ts holds the tool names, typed, so a tool renamed on the server stops the build of the page that documents it.

It has to be our own authorization server: the protocol forbids a resource accepting a token issued by anybody else, so a Neon Auth session cannot be handed to a client. What the client gets reaches documents and shares, and never the account, the sign-in or the API keys — the rule the keys already follow, applied to the credential it is most about. Connections are listed beside the keys in the account menu and revoked there, all tokens for a client at once, because revoking the access token alone leaves it able to mint another within the minute.

The tools call this app's own /api/v1 in process with the caller's credential forwarded, so a conversation and a script get the same answer from the same code. Eight tools take confirm: true and are refused without it: tp_save_document, tp_share_document and tp_share_folder publishing a link or sharing with people; tp_save_document, tp_move_documents and tp_update_folder putting content into a shared folder; tp_update_document on a document that is shared; and tp_delete_document and tp_delete_folder always. A grant can be read-only, and then the writing tools refuse in a sentence. Every tool carries all four MCP annotation hints as booleans, and every tool answers with an MCP Apps card for a client that draws them — a document, the document list, the account and its folders, one folder, the documentation, and a refused call with the button that confirms it. tp_help and tp_convert_markdown, the names two tools had before October 2026, still answer as aliases of tp_search_documentation and tp_convert_from_markdown, and are not listed.

npm run mcp:check runs the whole flow against a local server and the real database — discovery, the 401, registration, an unregistered redirect_uri, PKCE, a burnt code, refresh rotation, revocation and every tool — and cleans up after itself. The half that needs a browser (Google, then pressing Connect) is the one part it mints directly, exactly as /approve would.

Limits

Per account250 MB, 5,000 documents
Per conversion10 MB — roughly 1.5 million words. Runs in the browser, so this is a judgement about the machine, not a platform limit
Per kept document4 MB. A Vercel Function refuses a request or response body over 4.5 MB with a bare 413 the app never sees, so a bigger document could be neither saved nor read back. Raising it means keeping the Markdown out of the request: a client upload straight to blob storage and reads redirecting to a signed URL. Both numbers live in shared/limits.ts, with the reason
Per caller60 requests a minute, counted by key or by session; a connector's tool call counts once

Reaching a limit is a refusal, not a silent eviction: this app used to drop the oldest document to stay under its cap, which quietly destroyed something its owner had chosen to keep. A refusal says what to delete instead. server/limits.ts holds the numbers; the rate counter is a row per caller per minute in Postgres, which is imprecise under heavy concurrency and, at this size, the right trade against running a cache beside the database.

Abuse

A shared page carries other people's content on our domain, so it is served with script-src 'self' and frame-ancestors 'none' — an injection that somehow survived the sanitiser still cannot run, and the document cannot be framed as someone else's page. 'self' rather than 'none' since 5 October 2026: the page loads one script of ours, /share/diagrams.js (src/share-diagrams.ts), which draws Mermaid diagrams and puts a diagram or a wide table on the whole screen. Nothing inline runs — no <script> in a document, no event handler. Every shared page links to /report/<token>, a form that needs no JavaScript, and reports land in m2h_report.

Nothing is revoked automatically. npm run reports lists what is open, -- --revoke <id> kills the link and marks it handled, -- --dismiss <id> just marks it. A report is a stranger's claim about someone else's document, and both mistakes — leaving a phishing page up, killing an innocent link — deserve a person reading it first.

Blog and documentation

/docs is the manual and /blog is seventy articles about converting Markdown; both are pages of this app, built from the same design system, and the articles are Markdown files in content/blog/ rendered by the converter itself. There is no second pipeline to keep in step, and a bug in the renderer shows on our own pages before it shows on anyone else's document.

content/keywords.md is the keyword research the articles were written against — clusters by intent, each naming the article that targets it, and four marked as gaps that nothing covers yet. It carries no volume figures: they cannot be measured from here, and an invented number outlives the guess it came from.

A single-page app is invisible to a crawler — every route answers with the same shell and the same title — so npm run build ends with scripts/prerender.ts. It runs in Node, renders each article with the server-side converter, and writes a real file per route: dist/blog/<slug>/index.html with its own title, description, canonical link and BlogPosting data, the home page with its FAQPage, plus sitemap.xml and robots.txt (shared documents are kept out of search by an X-Robots-Tag: noindex header on every answer under /s/, not by robots.txt — a crawler barred from a page never reads the noindex on it). Vercel serves a matching file before it consults the rewrites, so those pages are static; the bundle still loads and takes over, and in-app navigation never touches them.

The FAQ is one list in src/lib/faq.ts, shown under the converter's dropzone and again in the docs. ArticleCard, SectionHeading and Faq live in src/ui/components with the rest of the design system, because each of them is used from three places and three near-identical copies read as three different products.

Addresses

/ is the converter, /history the list (with ?filter=html|md|shared for the chip it is showing), /docs the manual, /blog and /blog/<slug> the articles, /s/<token> a shared document. They are read straight from location rather than through a router — three routes do not need one — which is what makes a reload land where you were and the Back button work. Each needs a rewrite to index.html in vercel.json.

Where a document lives

Postgres keeps what the app queries — name, size, stats, share token, recipients. Those rows stay small however many documents there are. The Markdown source is never filtered on or sorted by, only fetched whole, and it is the only part that grows, so it goes to a Vercel Blob store instead: sources/<user>/<document>.md.

The store is private. A source is read on the server with the store's token and its URL never reaches a browser — a shared document is served by our own route, which is where access is decided.

Connecting the store to the project hands the function an OIDC identity and a BLOB_STORE_ID rather than a long-lived key, and that is the normal path; a BLOB_READ_WRITE_TOKEN is still honoured where one exists. With neither, the source is written to the markdown column exactly as before — so a checkout without store access still works, and rows written earlier still open.

Those older rows do not need a flag day: each moves into the store the first time it is read somewhere the store is reachable, and the reader gets its text either way. npm run blob:migrate does the same in one pass where that is preferred, and npm run blob:reconcile reports where store and database disagree — an orphaned file is safe to delete, a row whose file is gone is only reported.

The store's connection has to include the Development environment (Storage → the store → Configure → Environments), or a locally pulled OIDC token is refused and npm run dev writes sources to the column while production writes them to the store.

Rendering

The parse, the renderer overrides and the allow-list live in shared/ and run in both places: the browser renders the preview, the function renders the page a share link opens. One document, whoever asks for it.

Only the sanitiser differs, because only one of the two runtimes has a DOM: src/lib/markdown.ts uses DOMPurify over the browser's own, server/render.ts uses xss, which parses the HTML itself against the same allow-list. Emulating a DOM was tried first and is a trap worth writing down — jsdom broke the Node runtime outright (a CJS dependency requiring an ESM module), and the lighter stand-ins were worse: DOMPurify reported success and returned its input untouched, <script> and all. sanitize-html failed the same way jsdom did, and took sign-in down with it — which is why the renderer is now imported lazily inside the share route, where a broken renderer can only break that page. Heading ids carry a doc- prefix so both sanitisers keep them; a bare id="title" is DOM-clobbering, which the browser strips and a parser does not.

GET /s/<token> is served by the function, not by the app:

  • link share — built HTML with s-maxage=60, stale-while-revalidate=600, so repeat visitors are answered by the CDN and the database sees about one read a minute per document. The window is short on purpose: revoking a share has to take effect in about a minute.
  • addressed share — never cached. A reader who is signed in and on the list gets the same page privately; anyone else is redirected to /open/<token>, the app's own page, which knows how to ask them to sign in.
  • ?download — the same document as an attachment.

Structure

api/index.ts            Vercel entry point (wraps the Hono app)
shared/                 the converter and document styles, used by both runtimes
server/                 API: routes, Neon Auth proxy, Neon client, dev middleware
db/schema.sql           m2h_document and its sharing tables
scripts/init-db.mjs     applies the schema
scripts/auth-origin.mjs manages Neon Auth's trusted origins
server/mcp.ts           the MCP endpoint and its tools
server/oauth.ts         the authorization server, and the consent page
server/wellknown.ts     the two discovery documents
scripts/prerender.ts    a real HTML file per route, after the bundle is built
scripts/screenshots.mjs the documentation screenshots, captured from the running app
content/blog/           the articles; content/keywords.md is what they were written against
cli/tp.mjs             the command line client
action.yml              the GitHub Action (examples/ has a workflow to copy)
src/
  App.tsx               app shell and state
  components/           header, user menu, dropzone, preview, stats
  features/             ConverterPage, HistoryPage, DocsPage, BlogPage, ArticlePage
  lib/                  conversion, doc styles, auth, api client, history
  ui/                   design system (vendored)

Licence

MIT — see LICENSE. The brand is not part of it: brand/mark.svg, brand/logo.svg and the name are Raudar Labs's, so fork the code freely and put your own mark on it.

الأسئلة الشائعة

ما هو TransformPipe؟

Convert documents to Markdown and back, keep them, and share them as a page with one link.

كيف أثبّت TransformPipe؟

يعمل كخادم بعيد. في Claude Code: claude mcp add --transport http transformpipe 'https://transformpipe.com/api/mcp'. وفي العملاء الآخرين، الصق إعداد JSON من هذه الصفحة في إعدادات MCP الخاصة بهم.

هل يحتاج TransformPipe إلى مفتاح API؟

لا يذكر إدراجه في السجل أي مفتاح أو سر إلزامي. قد تتطلب بعض الميزات حسابًا لدى الخدمة التي يتصل بها.

هل TransformPipe مفتوح المصدر؟

نعم. شيفرته متاحة على GitHub بترخيص MIT.

من يصون TransformPipe؟

ينشره raudarlabs. يدرجه AI.tn لكنه لا يصونه ولا يوصي به.

من سجل MCP الرسمي. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.