Skip to content

DXJ-LLC/md-embed

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

md-embed (@dxj.jp/md-embed)

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 unscoped md-embed npm 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.

Why

  • 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.

Quickstart (Hono)

npm i @dxj.jp/md-embed
import { 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.

API

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)

Embedding from other sites (opt-in)

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. urlResolver is 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. Keep allow tight 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.

Options

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"

Element contract

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.

Conventions

  • Raw Markdown should be reachable at a .md alias URL (serve text/markdown for the same path); content negotiation via Accept headers is treated as sugar on top, never the canonical route.

Security

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.

Development

pnpm install
pnpm test    # vitest on real workerd (HTMLRewriter included)
pnpm build   # tsc → dist/

Roadmap

  • 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.txt generation from embedded sources

License

MIT — made by DXJ

About

Streaming <md-embed> shortcode renderer for Cloudflare Workers — live, R2-backed Markdown embedded into HTML via HTMLRewriter.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages