@dxj.jp/md-embed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DXJ (https://dxj.jp)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # md-embed (`@dxj.jp/md-embed`)
2
+
3
+ > **Live demo: [md-embed.dxj.jp](https://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](https://dxj.jp) and points here.
4
+
5
+ Embed live Markdown into any HTML page at the edge.
6
+
7
+ `md-embed` is a streaming shortcode renderer for Cloudflare Workers. Drop a custom element into your HTML:
8
+
9
+ ```html
10
+ <md-embed src="docs/intro.md"></md-embed>
11
+ ```
12
+
13
+ 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.
14
+
15
+ ## Why
16
+
17
+ - **Live content**: Markdown lives in R2 and is edited by humans *and* AI agents. Provenance attributes (`data-md-etag`) make cache invalidation trivial.
18
+ - **Streaming**: HTMLRewriter transforms the page as it streams — no full-page buffering.
19
+ - **One tag**: the `<md-embed>` custom element is the whole integration surface.
20
+
21
+ ## Quickstart (Hono)
22
+
23
+ ```sh
24
+ npm i @dxj.jp/md-embed
25
+ ```
26
+
27
+ ```ts
28
+ import { Hono } from "hono";
29
+ import { mdEmbed, r2Resolver } from "@dxj.jp/md-embed";
30
+
31
+ type Env = { CONTENT: R2Bucket; ASSETS: Fetcher };
32
+
33
+ const app = new Hono<{ Bindings: Env }>();
34
+
35
+ app.use(mdEmbed((env) => r2Resolver((env as Env).CONTENT)));
36
+ app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
37
+
38
+ export default app;
39
+ ```
40
+
41
+ Any `text/html` response passing through the middleware gets its `<md-embed>` elements resolved. Non-HTML responses are untouched.
42
+
43
+ ## API
44
+
45
+ ```ts
46
+ render(markdown, options?) // Markdown → HTML string (wrapped in <article class="prose">)
47
+ r2Resolver(bucket, { prefix? }) // MarkdownResolver backed by an R2 bucket
48
+ transform(response, resolver, opts?) // stream-resolve <md-embed> in a Response
49
+ mdEmbed(env => resolver, opts?) // Hono-compatible middleware (no hono dependency)
50
+ ```
51
+
52
+ ### Options
53
+
54
+ | Option | Default | Meaning |
55
+ |---|---|---|
56
+ | `wrapperClass` | `"prose"` | Class on the `<article>` wrapper; `null` disables wrapping |
57
+ | `markedOptions` | GFM on | Forwarded to [marked](https://marked.js.org) (`async` is always forced to `false`) |
58
+ | `tagName` | `"md-embed"` | Element to resolve (should contain a hyphen — custom-element convention) |
59
+ | `allowSrc` | allow all | Allowlist gate for `src` values; rejected embeds get `data-md-error="not-allowed"` |
60
+
61
+ ### Element contract
62
+
63
+ The `<md-embed>` element **stays in the DOM** after rendering — its inner content is replaced and attributes are stamped:
64
+
65
+ | Attribute | Meaning |
66
+ |---|---|
67
+ | `rendered` | Present when server-side rendering succeeded (a future client-side Web Component skips hydration when set) |
68
+ | `data-md-src` | The resolved source path |
69
+ | `data-md-etag` | Version identifier from the resolver (R2 `httpEtag`) |
70
+ | `data-md-error` | `missing-src` / `not-allowed` / `not-found` / `resolver-error` / `render-error` when rendering failed (element and its fallback content left as-is) |
71
+
72
+ 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.
73
+
74
+ **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>`.
75
+
76
+ **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.
77
+
78
+ ### Conventions
79
+
80
+ - 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.
81
+
82
+ ## Security
83
+
84
+ 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.
85
+
86
+ 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.
87
+
88
+ TypeScript consumers: `@cloudflare/workers-types` is an optional peer dependency — install it (or use `wrangler types`) so `R2Bucket` and friends resolve.
89
+
90
+ ## Development
91
+
92
+ ```sh
93
+ pnpm install
94
+ pnpm test # vitest on real workerd (HTMLRewriter included)
95
+ pnpm build # tsc → dist/
96
+ ```
97
+
98
+ ## Roadmap
99
+
100
+ - `0.1.0` — core (`render` / `r2Resolver` / `transform` / `mdEmbed`) — shipped, dogfooded on [md-embed.dxj.jp](https://md-embed.dxj.jp)
101
+ - Cache API integration (ETag-keyed, stale-while-revalidate)
102
+ - Client-side `<md-embed>` Web Component sharing this element contract
103
+ - `llms.txt` generation from embedded sources
104
+
105
+ ## License
106
+
107
+ MIT — made by [DXJ](https://dxj.jp)
@@ -0,0 +1,47 @@
1
+ import type { MarkedOptions } from "marked";
2
+ import type { R2Bucket } from "@cloudflare/workers-types";
3
+ export interface ResolvedMarkdown {
4
+ /** Raw Markdown source. */
5
+ markdown: string;
6
+ /** Opaque version identifier (e.g. R2 `httpEtag`). Emitted as `data-md-etag`. */
7
+ etag?: string;
8
+ }
9
+ /** Maps an `src` attribute value to Markdown content. Return null when the source does not exist. */
10
+ export type MarkdownResolver = (src: string) => Promise<ResolvedMarkdown | null>;
11
+ export interface MdEmbedOptions {
12
+ /** Class for the `<article>` wrapper around rendered output. `null` disables the wrapper. Default `"prose"`. */
13
+ wrapperClass?: string | null;
14
+ /** Options forwarded to `marked.parse`. GFM is enabled by default; `async` is always forced to `false`. */
15
+ markedOptions?: MarkedOptions;
16
+ /** Element tag to resolve. Should contain a hyphen (custom-element convention). Default `"md-embed"`. */
17
+ tagName?: string;
18
+ /** Allowlist gate for `src` values. Rejected elements get `data-md-error="not-allowed"`. */
19
+ allowSrc?: (src: string) => boolean;
20
+ }
21
+ /** Render Markdown to HTML (`##` → `<h2>`, GFM tables, etc.), wrapped for typography styling. */
22
+ export declare function render(markdown: string, options?: MdEmbedOptions): string;
23
+ /** Resolver backed by an R2 bucket. `src` attributes are used as object keys (with optional prefix). */
24
+ export declare function r2Resolver(bucket: R2Bucket, opts?: {
25
+ prefix?: string;
26
+ }): MarkdownResolver;
27
+ /**
28
+ * Resolve every `<md-embed>` in an HTML response via HTMLRewriter (streaming —
29
+ * the page is never fully buffered). Non-HTML responses pass through untouched.
30
+ *
31
+ * Upstream validators (`ETag`, `Last-Modified`) and `Content-Length` are
32
+ * stripped from transformed responses: they describe the original asset, not
33
+ * the composed page, and would let clients revalidate into stale embeds —
34
+ * breaking the "update the Markdown, see it instantly" contract.
35
+ */
36
+ export declare function transform(response: Response, resolve: MarkdownResolver, options?: MdEmbedOptions): Response;
37
+ /**
38
+ * Hono-compatible middleware (no dependency on hono itself). Always takes a
39
+ * factory so bindings can be read from the per-request `env`:
40
+ *
41
+ * app.use(mdEmbed<Env>(env => r2Resolver(env.CONTENT)))
42
+ * app.use(mdEmbed(() => myStaticResolver))
43
+ */
44
+ export declare function mdEmbed<E = unknown>(getResolver: (env: E) => MarkdownResolver, options?: MdEmbedOptions): (c: {
45
+ res: Response;
46
+ env: E;
47
+ }, next: () => Promise<void>) => Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * md-embed — streaming <md-embed> shortcode rendering for Cloudflare Workers.
3
+ *
4
+ * The <md-embed> element stays in the output DOM (custom-element semantics):
5
+ * its inner content is replaced with rendered HTML and provenance attributes
6
+ * are stamped on it, so a future client-side Web Component can skip hydration
7
+ * when `rendered` is present.
8
+ *
9
+ * Failures are contained per element: the element keeps its fallback content
10
+ * and gets a `data-md-error` attribute — one broken embed never breaks the page.
11
+ *
12
+ * Markdown is NOT sanitized — only embed content you control (e.g. your own
13
+ * R2 bucket). The embedding HTML must be fully trusted too: any markup
14
+ * injection on the page becomes a server-side include via the `src` attribute,
15
+ * so do not run this middleware over pages containing user-generated HTML
16
+ * (or restrict sources with `allowSrc`).
17
+ */
18
+ import { marked } from "marked";
19
+ const DEFAULT_TAG = "md-embed";
20
+ const DEFAULT_WRAPPER_CLASS = "prose";
21
+ const escapeAttr = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
22
+ /** Render Markdown to HTML (`##` → `<h2>`, GFM tables, etc.), wrapped for typography styling. */
23
+ export function render(markdown, options = {}) {
24
+ const html = marked.parse(markdown, {
25
+ gfm: true,
26
+ ...options.markedOptions,
27
+ async: false,
28
+ });
29
+ const wrapperClass = options.wrapperClass === undefined ? DEFAULT_WRAPPER_CLASS : options.wrapperClass;
30
+ return wrapperClass === null
31
+ ? html
32
+ : `<article class="${escapeAttr(wrapperClass)}">${html}</article>`;
33
+ }
34
+ /** Resolver backed by an R2 bucket. `src` attributes are used as object keys (with optional prefix). */
35
+ export function r2Resolver(bucket, opts = {}) {
36
+ return async (src) => {
37
+ const object = await bucket.get((opts.prefix ?? "") + src);
38
+ if (!object)
39
+ return null;
40
+ return { markdown: await object.text(), etag: object.httpEtag };
41
+ };
42
+ }
43
+ class MdEmbedHandler {
44
+ resolve;
45
+ options;
46
+ constructor(resolve, options) {
47
+ this.resolve = resolve;
48
+ this.options = options;
49
+ }
50
+ async element(el) {
51
+ if (el.getAttribute("rendered") !== null)
52
+ return; // already rendered upstream
53
+ const src = el.getAttribute("src");
54
+ if (!src) {
55
+ el.setAttribute("data-md-error", "missing-src");
56
+ return;
57
+ }
58
+ if (this.options.allowSrc && !this.options.allowSrc(src)) {
59
+ el.setAttribute("data-md-error", "not-allowed");
60
+ return;
61
+ }
62
+ let resolved;
63
+ try {
64
+ resolved = await this.resolve(src);
65
+ }
66
+ catch {
67
+ el.setAttribute("data-md-error", "resolver-error");
68
+ return;
69
+ }
70
+ if (!resolved) {
71
+ el.setAttribute("data-md-error", "not-found");
72
+ return;
73
+ }
74
+ let html;
75
+ try {
76
+ html = render(resolved.markdown, this.options);
77
+ }
78
+ catch {
79
+ el.setAttribute("data-md-error", "render-error");
80
+ return;
81
+ }
82
+ el.setInnerContent(html, { html: true });
83
+ el.setAttribute("rendered", "");
84
+ el.setAttribute("data-md-src", src);
85
+ if (resolved.etag)
86
+ el.setAttribute("data-md-etag", resolved.etag);
87
+ }
88
+ }
89
+ /**
90
+ * Resolve every `<md-embed>` in an HTML response via HTMLRewriter (streaming —
91
+ * the page is never fully buffered). Non-HTML responses pass through untouched.
92
+ *
93
+ * Upstream validators (`ETag`, `Last-Modified`) and `Content-Length` are
94
+ * stripped from transformed responses: they describe the original asset, not
95
+ * the composed page, and would let clients revalidate into stale embeds —
96
+ * breaking the "update the Markdown, see it instantly" contract.
97
+ */
98
+ export function transform(response, resolve, options = {}) {
99
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
100
+ if (!contentType.includes("text/html"))
101
+ return response;
102
+ const transformed = new HTMLRewriter()
103
+ .on(options.tagName ?? DEFAULT_TAG, new MdEmbedHandler(resolve, options))
104
+ .transform(response);
105
+ const out = new Response(transformed.body, transformed);
106
+ out.headers.delete("etag");
107
+ out.headers.delete("last-modified");
108
+ out.headers.delete("content-length");
109
+ return out;
110
+ }
111
+ /**
112
+ * Hono-compatible middleware (no dependency on hono itself). Always takes a
113
+ * factory so bindings can be read from the per-request `env`:
114
+ *
115
+ * app.use(mdEmbed<Env>(env => r2Resolver(env.CONTENT)))
116
+ * app.use(mdEmbed(() => myStaticResolver))
117
+ */
118
+ export function mdEmbed(getResolver, options = {}) {
119
+ return async (c, next) => {
120
+ await next();
121
+ c.res = transform(c.res, getResolver(c.env), options);
122
+ };
123
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@dxj.jp/md-embed",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Streaming <md-embed> shortcode renderer for Cloudflare Workers: embeds live, R2-backed Markdown into HTML via HTMLRewriter.",
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "test": "vitest run",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "markdown",
28
+ "cloudflare-workers",
29
+ "htmlrewriter",
30
+ "hono",
31
+ "shortcode",
32
+ "custom-element",
33
+ "md-embed",
34
+ "r2",
35
+ "edge"
36
+ ],
37
+ "author": "DXJ (https://dxj.jp)",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/kyutarou/md-embed.git"
42
+ },
43
+ "pnpm": {
44
+ "onlyBuiltDependencies": [
45
+ "workerd",
46
+ "esbuild"
47
+ ]
48
+ },
49
+ "dependencies": {
50
+ "marked": "^18.0.6"
51
+ },
52
+ "peerDependencies": {
53
+ "@cloudflare/workers-types": ">=4.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@cloudflare/workers-types": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
62
+ "@cloudflare/workers-types": "^5.20260712.1",
63
+ "typescript": "^7.0.2",
64
+ "vitest": "^4.1.10",
65
+ "wrangler": "^4.110.0"
66
+ }
67
+ }