Live demo: md-embed.dxj.jp — every block of prose on that page is Markdown in R2, rendered at request time by this package. Tested on real workerd via
@cloudflare/vitest-pool-workers. The unscopedmd-embednpm name is held by DXJ and points here.
Embed live Markdown into any HTML page at the edge.
md-embed is a streaming shortcode renderer for Cloudflare Workers. Drop a custom element into your HTML:
<md-embed src="docs/intro.md"></md-embed>and the Worker resolves it at request time with HTMLRewriter — fetching the Markdown from R2, rendering it to clean HTML (## → <h2>, GFM tables, and more), and streaming it into place. Update the Markdown in R2 and every embedding page reflects it instantly, with no redeploy.
- Live content: Markdown lives in R2 and is edited by humans and AI agents. Provenance attributes (
data-md-etag) make cache invalidation trivial. - Streaming: HTMLRewriter transforms the page as it streams — no full-page buffering.
- One tag: the
<md-embed>custom element is the whole integration surface.
npm i @dxj.jp/md-embedimport { Hono } from "hono";
import { mdEmbed, r2Resolver } from "@dxj.jp/md-embed";
type Env = { CONTENT: R2Bucket; ASSETS: Fetcher };
const app = new Hono<{ Bindings: Env }>();
app.use(mdEmbed((env) => r2Resolver((env as Env).CONTENT)));
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
export default app;Any text/html response passing through the middleware gets its <md-embed> elements resolved. Non-HTML responses are untouched.
render(markdown, options?) // Markdown → HTML string (wrapped in <article class="prose">)
r2Resolver(bucket, { prefix? }) // MarkdownResolver backed by an R2 bucket
urlResolver({ allow, ... }) // opt-in: fetch Markdown from allowlisted external URLs
combineResolvers(...resolvers) // try resolvers in order; first non-null wins
transform(response, resolver, opts?) // stream-resolve <md-embed> in a Response
mdEmbed(env => resolver, opts?) // Hono-compatible middleware (no hono dependency)By default src is resolved locally only (an R2 key) — external URLs are never
fetched. To allow <md-embed src="https://…">, opt in with urlResolver, which is
default-deny: it fetches nothing until you supply an allow rule, and only ever
touches absolute URLs, so it composes with r2Resolver:
app.use(mdEmbed<Env>((env) => combineResolvers(
urlResolver({ allow: "https://raw.githubusercontent.com/DXJ-LLC/" }),
r2Resolver(env.CONTENT),
)));Now <md-embed src="https://raw.githubusercontent.com/DXJ-LLC/md-embed/main/README.md">
renders, while a URL outside the allowlist falls through to R2 and ends up not-found.
allow accepts a string (matched by origin + path prefix, so "https://host" and
"https://host/dir/" are both host-boundary-safe — a bare prefix can't be spoofed by
https://host.evil.com), a RegExp (tested against href — anchor it), a predicate
(url: URL) => boolean, or an array of these. Only https: is fetched by default
(protocols to change), and init is forwarded to fetch.
Two independent gates guard resolution: the element-level allowSrc option runs
first and shows a visible data-md-error="not-allowed"; the fetch-level urlResolver
allow silently falls through (so a rejected URL ends up not-found via the next
resolver, revealing nothing about the allowlist).
Security. External Markdown is untrusted, unsanitized third-party input — an allowlisted host that gets compromised can inject HTML into your page.
urlResolveris default-deny and does not follow redirects by default (redirect: "manual", so a 3xx resolves to nothing) so a host can't 302 off-allowlist; if you opt back in, the landing URL is re-checked. Keepallowtight and prefer hosts you control. On Workers the edge has no private network, so this is an XSS/abuse surface rather than a classic internal-SSRF one — but treat it as a real trust boundary regardless.
| Option | Default | Meaning |
|---|---|---|
wrapperClass |
"prose" |
Class on the <article> wrapper; null disables wrapping |
markedOptions |
GFM on | Forwarded to marked (async is always forced to false) |
tagName |
"md-embed" |
Element to resolve (should contain a hyphen — custom-element convention) |
allowSrc |
allow all | Allowlist gate for src values; rejected embeds get data-md-error="not-allowed" |
The <md-embed> element stays in the DOM after rendering — its inner content is replaced and attributes are stamped:
| Attribute | Meaning |
|---|---|
rendered |
Present when server-side rendering succeeded (a future client-side Web Component skips hydration when set) |
data-md-src |
The resolved source path |
data-md-etag |
Version identifier from the resolver (R2 httpEtag) |
data-md-error |
missing-src / not-allowed / not-found / resolver-error / render-error when rendering failed (element and its fallback content left as-is) |
Elements that already carry rendered are skipped, so layered rendering (edge cache → origin) is safe. Failures are contained per element — one broken embed never breaks the page.
Closing tag required. HTML has no self-closing custom elements: <md-embed src="…" /> is parsed as an open tag and will swallow everything up to its parent's close. Always write <md-embed src="…"></md-embed>.
Headers. Transformed responses have upstream ETag, Last-Modified, and Content-Length stripped — those validators describe the original asset, not the composed page, and would let browsers revalidate into stale embeds.
- Raw Markdown should be reachable at a
.mdalias URL (servetext/markdownfor the same path); content negotiation viaAcceptheaders is treated as sugar on top, never the canonical route.
Rendered Markdown is not sanitized — embed only content you control (your own R2 bucket, your repo). Do not point a resolver at untrusted third-party input.
The src attribute is a server-side include surface: the embedding HTML must be fully trusted too. Do not run the middleware over pages containing user-generated markup — anyone who can inject a tag can inline any object your resolver reaches (e.g. unpublished drafts). Use allowSrc to restrict resolvable sources, and note that r2Resolver treats keys as literals (no ../ traversal), but custom HTTP/filesystem resolvers must enforce that themselves.
TypeScript consumers: @cloudflare/workers-types is an optional peer dependency — install it (or use wrangler types) so R2Bucket and friends resolve.
pnpm install
pnpm test # vitest on real workerd (HTMLRewriter included)
pnpm build # tsc → dist/0.1.0— core (render/r2Resolver/transform/mdEmbed) — shipped, dogfooded on md-embed.dxj.jp- Cache API integration (ETag-keyed, stale-while-revalidate)
- Client-side
<md-embed>Web Component sharing this element contract llms.txtgeneration from embedded sources
MIT — made by DXJ