react-email-rails 0.3.0 → 0.4.1

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/bin/build.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createBuilder } from "vite"
3
- import { fail, isolatedViteConfig, loadReactEmailRailsConfig } from "./shared.mjs"
4
- import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
3
+ import {
4
+ exitIfHealthCheck,
5
+ fail,
6
+ isolatedViteConfig,
7
+ loadReactEmailRailsConfig,
8
+ } from "./shared.mjs"
5
9
 
6
- if (process.argv.includes("--health")) {
7
- process.stdout.write(
8
- JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
9
- )
10
- process.exit(0)
11
- }
10
+ exitIfHealthCheck()
12
11
 
13
12
  const args = process.argv.slice(2)
14
13
  const readOption = (long, short) => {
@@ -52,9 +51,8 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
52
51
  configLoader,
53
52
  })
54
53
 
55
- // Build production emails with the same isolation principle as the dev renderer:
56
- // keep component resolution and transforms, but leave unrelated app plugins out
57
- // of the email environment so client/global plugin hooks cannot break SSR output.
54
+ // Same isolation as the dev renderer: keep component resolve/transforms but leave unrelated
55
+ // app plugins out of the email environment so their hooks can't break SSR output.
58
56
  const builder = await createBuilder(
59
57
  isolatedViteConfig(userConfig, vite, {
60
58
  root: root ?? userConfig.root,
package/bin/dev.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer, isRunnableDevEnvironment } from "vite"
3
- import { fail, isolatedViteConfig, loadReactEmailRailsConfig } from "./shared.mjs"
4
- import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
3
+ import {
4
+ exitIfHealthCheck,
5
+ fail,
6
+ isolatedViteConfig,
7
+ loadReactEmailRailsConfig,
8
+ } from "./shared.mjs"
5
9
 
6
- if (process.argv.includes("--health")) {
7
- process.stdout.write(
8
- JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
9
- )
10
- process.exit(0)
11
- }
10
+ exitIfHealthCheck()
12
11
 
13
12
  const toStderr = (message) => process.stderr.write(`${message}\n`)
14
13
  const logger = {
@@ -39,9 +38,7 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
39
38
  mode: "development",
40
39
  })
41
40
 
42
- // Forward config that affects how components resolve and compile (but not the
43
- // host's dev-server plugins, which have global side effects), so dev rendering
44
- // stays close to the production email bundle.
41
+ // Forward only component resolve/compile config, so dev rendering stays close to the build.
45
42
  const server = await createServer(
46
43
  isolatedViteConfig(userConfig, vite, {
47
44
  configFile: false,
@@ -53,8 +50,7 @@ const server = await createServer(
53
50
  }),
54
51
  )
55
52
 
56
- // Render through the same `email` environment the production build uses, so dev
57
- // and build resolve and compile components identically.
53
+ // Render through the same `email` environment as the production build, so the two match.
58
54
  const environment = server.environments.email
59
55
  if (!isRunnableDevEnvironment(environment)) {
60
56
  await server.close()
@@ -63,6 +59,7 @@ if (!isRunnableDevEnvironment(environment)) {
63
59
 
64
60
  try {
65
61
  const { run } = await environment.runner.import("virtual:react-email-rails/server")
62
+ // Restore before run(): serve() re-isolates stdout with its own protocol writer.
66
63
  restoreStdout()
67
64
  await run()
68
65
  } finally {
package/bin/shared.mjs CHANGED
@@ -1,7 +1,11 @@
1
1
  import { loadConfigFromFile, mergeConfig } from "vite"
2
2
 
3
+ import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
4
+
5
+ // Wire contract: must match the Symbol.for(...) keys in src/index.ts.
3
6
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
4
7
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
8
+ // Must mirror ReactEmailRailsViteOptions in src/index.ts and the option list in README.md.
5
9
  const EMAIL_VITE_CONFIG_KEYS = [
6
10
  "assetsInclude",
7
11
  "css",
@@ -13,6 +17,16 @@ const EMAIL_VITE_CONFIG_KEYS = [
13
17
  "resolve",
14
18
  ]
15
19
 
20
+ // Emit the version/protocol handshake and exit on --health. Shared by the build and dev bins.
21
+ export function exitIfHealthCheck() {
22
+ if (!process.argv.includes("--health")) return
23
+
24
+ process.stdout.write(
25
+ JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
26
+ )
27
+ process.exit(0)
28
+ }
29
+
16
30
  export async function loadReactEmailRailsConfig({
17
31
  command,
18
32
  mode,
@@ -1,6 +1,6 @@
1
1
  import { type Extensions } from "@tiptap/core";
2
- import type { DroppedNode, ParseResult, RenderResult } from "./runtime.js";
3
- export type { DroppedNode };
2
+ import type { DroppedNode, ParseDocumentRequest, ParseResult, RenderDocumentRequest, RenderResult } from "./runtime.js";
3
+ export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest };
4
4
  export type DocumentRenderer = {
5
5
  buildExtensions: (context: unknown) => Extensions;
6
6
  transformDocument?: (document: unknown, context: unknown) => unknown;
@@ -8,20 +8,12 @@ export type DocumentRenderer = {
8
8
  };
9
9
  export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>);
10
10
  export type DocumentRegistry = Record<string, DocumentLoader>;
11
- export type RenderDocumentRequest = {
12
- kind: "document";
13
- type: string;
14
- document: unknown;
15
- context?: unknown;
16
- preview?: string | null;
17
- };
18
- export type ParseDocumentRequest = {
19
- kind: "parse";
20
- type: string;
21
- html: string;
22
- context?: unknown;
23
- };
24
11
  type GenerateJSON = (html: string, extensions: Extensions) => unknown;
12
+ type RenderMarkdown = (markdown: string) => string | Promise<string>;
13
+ type ParseDependencies = {
14
+ generateJSON?: GenerateJSON;
15
+ renderMarkdown?: RenderMarkdown;
16
+ };
25
17
  export declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
26
- export declare function parseDocument(request: ParseDocumentRequest, registry: DocumentRegistry, generateJSON?: GenerateJSON): Promise<ParseResult>;
27
- export declare function createParseDocument(generateJSON: GenerateJSON): (request: ParseDocumentRequest, registry: DocumentRegistry) => Promise<ParseResult>;
18
+ export declare function parseDocument(request: ParseDocumentRequest, registry: DocumentRegistry, dependencies?: ParseDependencies): Promise<ParseResult>;
19
+ export declare function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown): (request: ParseDocumentRequest, registry: DocumentRegistry) => Promise<ParseResult>;
package/dist/document.js CHANGED
@@ -7,9 +7,8 @@ import { getSchema, resolveExtensions } from "@tiptap/core";
7
7
  const STRUCTURAL_NODE_TYPES = new Set(resolveExtensions([StarterKit, EmailTheming])
8
8
  .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
9
9
  .map((extension) => extension.name));
10
- // composeReactEmail renders a node as null when no extension matches its type or
11
- // the match is not an EmailNode. Mirror that predicate over the document so
12
- // warnings report the silent case: an in-schema node with no email renderer.
10
+ // composeReactEmail renders a node as null when no extension matches or the match isn't an
11
+ // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
13
12
  function collectDroppedNodes(document, extensions) {
14
13
  const byName = new Map();
15
14
  for (const extension of extensions)
@@ -56,6 +55,75 @@ async function loadGenerateJSON() {
56
55
  }
57
56
  throw new Error("@tiptap/html is missing the expected generateJSON export; check the installed version");
58
57
  }
58
+ async function loadRenderMarkdown() {
59
+ try {
60
+ const mod = (await import(/* @vite-ignore */ "marked"));
61
+ const marked = mod.marked;
62
+ if (marked && typeof marked.parse === "function") {
63
+ const parse = marked.parse.bind(marked);
64
+ return (markdown) => parse(markdown);
65
+ }
66
+ }
67
+ catch (error) {
68
+ throw new Error(`marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`);
69
+ }
70
+ throw new Error("marked is missing the expected parse export; check the installed version");
71
+ }
72
+ // The schema whitelists nodes and attributes but never validates URI protocols, so a
73
+ // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
74
+ const ALLOWED_URI_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
75
+ // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
76
+ // Built numerically to keep the source free of literal control characters.
77
+ const URI_IGNORED_RANGES = [
78
+ [0x00, 0x20],
79
+ [0xa0, 0xa0],
80
+ [0x1680, 0x1680],
81
+ [0x180e, 0x180e],
82
+ [0x2000, 0x2029],
83
+ [0x205f, 0x205f],
84
+ [0x3000, 0x3000],
85
+ [0xfeff, 0xfeff],
86
+ ];
87
+ const escapeCodePoint = (code) => "\\u" + code.toString(16).padStart(4, "0");
88
+ const URI_IGNORED_CHARS = new RegExp("[" +
89
+ URI_IGNORED_RANGES.map(([lo, hi]) => escapeCodePoint(lo) + "-" + escapeCodePoint(hi)).join("") +
90
+ "]", "g");
91
+ function hasAllowedUriScheme(uri) {
92
+ // No scheme → relative/anchor/query; nothing to neutralize.
93
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1];
94
+ return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase());
95
+ }
96
+ // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
97
+ // toJSON() output, so mutation is safe.
98
+ function neutralizeUnsafeUris(value) {
99
+ if (Array.isArray(value)) {
100
+ for (const item of value)
101
+ neutralizeUnsafeUris(item);
102
+ return;
103
+ }
104
+ if (value === null || typeof value !== "object")
105
+ return;
106
+ const node = value;
107
+ const attrs = node.attrs;
108
+ if (attrs && typeof attrs.href === "string" && !hasAllowedUriScheme(attrs.href)) {
109
+ attrs.href = "";
110
+ }
111
+ neutralizeUnsafeUris(node.marks);
112
+ neutralizeUnsafeUris(node.content);
113
+ }
114
+ // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
115
+ async function resolveHtmlInput(request, dependencies) {
116
+ const hasHtml = request.html !== undefined;
117
+ const hasMarkdown = request.markdown !== undefined;
118
+ if (hasHtml === hasMarkdown) {
119
+ throw new Error("parse request must include exactly one of `html` or `markdown`");
120
+ }
121
+ if (hasMarkdown) {
122
+ const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown());
123
+ return renderMarkdown(request.markdown);
124
+ }
125
+ return request.html;
126
+ }
59
127
  export async function composeDocument(request, registry) {
60
128
  // Fail legibly if the optional editor peers are present but their shape shifted.
61
129
  if (typeof composeReactEmail !== "function" ||
@@ -82,15 +150,19 @@ export async function composeDocument(request, registry) {
82
150
  const { html, text } = await composeReactEmail(params);
83
151
  return warnings.length > 0 ? { html, text, warnings } : { html, text };
84
152
  }
85
- export async function parseDocument(request, registry, generateJSON) {
86
- const parseHTML = generateJSON ?? (await loadGenerateJSON());
153
+ export async function parseDocument(request, registry, dependencies = {}) {
87
154
  const renderer = await resolveRenderer(request.type, registry);
88
155
  const extensions = resolveExtensions(renderer.buildExtensions(request.context));
89
156
  const schema = getSchema(extensions);
90
- const parsed = parseHTML(request.html, extensions);
157
+ const html = await resolveHtmlInput(request, dependencies);
158
+ const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON());
159
+ const parsed = parseHTML(html, extensions);
91
160
  const document = schema.nodeFromJSON(parsed).toJSON();
161
+ neutralizeUnsafeUris(document);
92
162
  return { document };
93
163
  }
94
- export function createParseDocument(generateJSON) {
95
- return (request, registry) => parseDocument(request, registry, generateJSON);
164
+ export function createParseDocument(generateJSON, renderMarkdown) {
165
+ // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
166
+ const dependencies = renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown };
167
+ return (request, registry) => parseDocument(request, registry, dependencies);
96
168
  }
package/dist/index.js CHANGED
@@ -11,16 +11,16 @@ const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
11
11
  const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/;
12
12
  // The dedicated build environment that emits the server-side email bundle.
13
13
  export const EMAIL_ENVIRONMENT = "email";
14
+ // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
14
15
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
15
16
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
17
+ // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
16
18
  const OUT_DIR = "tmp/react-email-rails";
17
19
  const BUNDLE_FILE = "emails.js";
18
20
  const require = createRequire(import.meta.url);
19
- // happy-dom (pulled in by @tiptap/html when parsing) depends on `ws`, which guards
20
- // optional native-addon requires behind these env flags. A standalone (noExternal)
21
- // build would otherwise try to bundle the uninstalled `bufferutil`/`utf-8-validate`
22
- // and fail at load. Setting the flags lets Rollup tree-shake those require branches
23
- // away; ws uses its pure-JS implementation, which is all the HTML parser needs.
21
+ // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
22
+ // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
23
+ // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
24
24
  const WS_NATIVE_ADDON_OPT_OUT = {
25
25
  "process.env.WS_NO_BUFFER_UTIL": "'1'",
26
26
  "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
@@ -81,18 +81,23 @@ export function reactEmailRails(options = {}) {
81
81
  filter: { id: VIRTUAL_MODULE_PATTERN },
82
82
  handler(id) {
83
83
  if (id === RESOLVED_SERVER) {
84
- const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`];
84
+ const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`];
85
85
  const parserPeersAvailable = documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"]);
86
+ // Markdown parsing layers `marked` on top of the HTML parser peers.
87
+ const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"]);
86
88
  if (documentSource) {
87
89
  lines.push(parserPeersAvailable
88
90
  ? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
89
91
  : `import { composeDocument, parseDocument } from "react-email-rails/document"`);
90
- if (parserPeersAvailable)
92
+ if (parserPeersAvailable) {
91
93
  lines.push(`import { generateJSON } from "@tiptap/html"`);
94
+ if (markdownPeerAvailable)
95
+ lines.push(`import { marked } from "marked"`);
96
+ }
92
97
  }
93
- lines.push(`const modules = import.meta.glob(${emailSource.globArg})`, `const extensions = ${JSON.stringify(emailSource.extensions)}`, `const registry = Object.create(null)`, `for (const path in modules) {`, ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`, `}`);
98
+ lines.push(`const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`);
94
99
  if (documentSource) {
95
- lines.push(`const documentModules = import.meta.glob(${documentSource.globArg})`, `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`, `const documentRegistry = Object.create(null)`, `for (const path in documentModules) {`, ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`, `}`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? "createParseDocument(generateJSON)" : "parseDocument"} })`);
100
+ lines.push(`const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`);
96
101
  }
97
102
  else {
98
103
  lines.push(`export const run = () => serve(registry)`);
@@ -105,13 +110,9 @@ export function reactEmailRails(options = {}) {
105
110
  },
106
111
  },
107
112
  config(_config, env) {
108
- // Register a dedicated `email` build environment. The official
109
- // react-email-rails-build bin opts into building it with an isolated
110
- // plugin stack so host app plugins cannot break email SSR builds.
111
- // The environment is a server consumer. Production standalone builds inline
112
- // Node dependencies by default so Rails runtime images do not need
113
- // node_modules; dev rendering keeps dependencies external for Vite's module
114
- // runner.
113
+ // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
114
+ // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
115
+ // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
115
116
  return {
116
117
  environments: {
117
118
  [EMAIL_ENVIRONMENT]: {
package/dist/runtime.d.ts CHANGED
@@ -27,7 +27,8 @@ export type RenderDocumentRequest = {
27
27
  export type ParseDocumentRequest = {
28
28
  kind: "parse";
29
29
  type: string;
30
- html: string;
30
+ html?: string;
31
+ markdown?: string;
31
32
  context?: unknown;
32
33
  };
33
34
  export type DroppedNode = {
@@ -50,5 +51,6 @@ export type EmailRenderOptions = {
50
51
  text?: ReactEmailRenderOptions;
51
52
  };
52
53
  export declare function toComponentName(globPath: string, root: string, extension: string): string;
54
+ export declare function buildRegistry(modules: EmailRegistry, extensions: string[], root: string): EmailRegistry;
53
55
  export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
54
56
  export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -5,6 +5,15 @@ export function toComponentName(globPath, root, extension) {
5
5
  const start = globPath.lastIndexOf(root) + root.length;
6
6
  return globPath.slice(start, globPath.length - extension.length);
7
7
  }
8
+ // Map glob results to a component-name registry (used for both email and document registries).
9
+ export function buildRegistry(modules, extensions, root) {
10
+ const registry = Object.create(null);
11
+ for (const [path, loader] of Object.entries(modules)) {
12
+ const extension = extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."));
13
+ registry[toComponentName(path, root, extension)] = loader;
14
+ }
15
+ return registry;
16
+ }
8
17
  export async function renderEmail(request, registry) {
9
18
  const loader = registry[request.component];
10
19
  if (!loader)
@@ -20,12 +29,11 @@ export async function renderEmail(request, registry) {
20
29
  function isDocumentRequest(request) {
21
30
  return (request !== null &&
22
31
  typeof request === "object" &&
32
+ "kind" in request &&
23
33
  request.kind === "document");
24
34
  }
25
35
  function isParseRequest(request) {
26
- return (request !== null &&
27
- typeof request === "object" &&
28
- request.kind === "parse");
36
+ return (request !== null && typeof request === "object" && "kind" in request && request.kind === "parse");
29
37
  }
30
38
  function isHealthRequest(request) {
31
39
  return request !== null && typeof request === "object" && "health" in request;
@@ -65,13 +73,15 @@ export async function serve(registry, documents = null) {
65
73
  process.exitCode = 1;
66
74
  }
67
75
  }
68
- // Reserve stdout for the JSON render protocol. Stray writes from email components
69
- // or their dependencies including console.log, which Node routes through
70
- // process.stdout.write are diverted to stderr so they cannot corrupt or desync
71
- // a response frame. Returns the writer to use for protocol output.
76
+ // Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
77
+ // routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
78
+ // Returns the writer to use for protocol output.
72
79
  function isolateStdout() {
73
80
  const protocolWrite = process.stdout.write.bind(process.stdout);
74
- process.stdout.write = ((chunk) => process.stderr.write(chunk));
81
+ // Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
82
+ process.stdout.write = ((chunk, encoding, callback) => typeof encoding === "function"
83
+ ? process.stderr.write(chunk, encoding)
84
+ : process.stderr.write(chunk, encoding, callback));
75
85
  return (chunk) => protocolWrite(chunk);
76
86
  }
77
87
  function readStdin() {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.3.0";
1
+ export declare const VERSION = "0.4.1";
2
2
  export declare const RENDER_PROTOCOL_VERSION = 3;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.3.0";
1
+ export const VERSION = "0.4.1";
2
2
  export const RENDER_PROTOCOL_VERSION = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email-rails",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Build and send emails using React and Rails",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,6 +66,7 @@
66
66
  "@tiptap/core": "^3",
67
67
  "@tiptap/html": "^3",
68
68
  "happy-dom": "^20.8.9",
69
+ "marked": "^18",
69
70
  "react": "^18.0 || ^19.0",
70
71
  "vite": "^7.0.0 || ^8.0.0"
71
72
  },
@@ -81,6 +82,9 @@
81
82
  },
82
83
  "happy-dom": {
83
84
  "optional": true
85
+ },
86
+ "marked": {
87
+ "optional": true
84
88
  }
85
89
  },
86
90
  "devDependencies": {
@@ -93,6 +97,7 @@
93
97
  "@types/react": "^19.2.15",
94
98
  "@typescript/native-preview": "7.0.0-dev.20260527.2",
95
99
  "happy-dom": "^20.8.9",
100
+ "marked": "^18.0.5",
96
101
  "oxfmt": "^0.52.0",
97
102
  "oxlint": "^1.67.0",
98
103
  "oxlint-tsgolint": "^0.23.0",
package/src/document.ts CHANGED
@@ -4,9 +4,16 @@ import { EmailTheming } from "@react-email/editor/plugins"
4
4
  import { getSchema, resolveExtensions, type Extensions } from "@tiptap/core"
5
5
  import type { Editor } from "@tiptap/core"
6
6
 
7
- import type { DroppedNode, ParseResult, RenderResult } from "./runtime.js"
7
+ import type {
8
+ DroppedNode,
9
+ ParseDocumentRequest,
10
+ ParseResult,
11
+ RenderDocumentRequest,
12
+ RenderResult,
13
+ } from "./runtime.js"
8
14
 
9
- export type { DroppedNode }
15
+ // Re-exported from runtime.ts (the single source) to keep react-email-rails/document's surface.
16
+ export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest }
10
17
 
11
18
  export type DocumentRenderer = {
12
19
  buildExtensions: (context: unknown) => Extensions
@@ -22,9 +29,8 @@ const STRUCTURAL_NODE_TYPES: ReadonlySet<string> = new Set(
22
29
  .map((extension) => extension.name),
23
30
  )
24
31
 
25
- // composeReactEmail renders a node as null when no extension matches its type or
26
- // the match is not an EmailNode. Mirror that predicate over the document so
27
- // warnings report the silent case: an in-schema node with no email renderer.
32
+ // composeReactEmail renders a node as null when no extension matches or the match isn't an
33
+ // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
28
34
  function collectDroppedNodes(document: unknown, extensions: Extensions): DroppedNode[] {
29
35
  const byName = new Map<string, Extensions[number]>()
30
36
  for (const extension of extensions) byName.set(extension.name, extension)
@@ -53,23 +59,15 @@ function collectDroppedNodes(document: unknown, extensions: Extensions): Dropped
53
59
  export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>)
54
60
  export type DocumentRegistry = Record<string, DocumentLoader>
55
61
 
56
- export type RenderDocumentRequest = {
57
- kind: "document"
58
- type: string
59
- document: unknown
60
- context?: unknown
61
- preview?: string | null
62
- }
62
+ type GenerateJSON = (html: string, extensions: Extensions) => unknown
63
+ type RenderMarkdown = (markdown: string) => string | Promise<string>
63
64
 
64
- export type ParseDocumentRequest = {
65
- kind: "parse"
66
- type: string
67
- html: string
68
- context?: unknown
65
+ // Bound at build time by createParseDocument when the peers are bundled; lazy-loaded otherwise.
66
+ type ParseDependencies = {
67
+ generateJSON?: GenerateJSON
68
+ renderMarkdown?: RenderMarkdown
69
69
  }
70
70
 
71
- type GenerateJSON = (html: string, extensions: Extensions) => unknown
72
-
73
71
  async function resolveRenderer(
74
72
  type: string,
75
73
  registry: DocumentRegistry,
@@ -102,6 +100,101 @@ async function loadGenerateJSON(): Promise<GenerateJSON> {
102
100
  )
103
101
  }
104
102
 
103
+ async function loadRenderMarkdown(): Promise<RenderMarkdown> {
104
+ try {
105
+ const mod = (await import(/* @vite-ignore */ "marked")) as {
106
+ marked?: { parse?: (markdown: string) => string | Promise<string> }
107
+ }
108
+
109
+ const marked = mod.marked
110
+ if (marked && typeof marked.parse === "function") {
111
+ const parse = marked.parse.bind(marked)
112
+ return (markdown) => parse(markdown)
113
+ }
114
+ } catch (error) {
115
+ throw new Error(
116
+ `marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`,
117
+ )
118
+ }
119
+
120
+ throw new Error("marked is missing the expected parse export; check the installed version")
121
+ }
122
+
123
+ // The schema whitelists nodes and attributes but never validates URI protocols, so a
124
+ // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
125
+ const ALLOWED_URI_SCHEMES: ReadonlySet<string> = new Set(["http", "https", "mailto", "tel"])
126
+
127
+ // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
128
+ // Built numerically to keep the source free of literal control characters.
129
+ const URI_IGNORED_RANGES: ReadonlyArray<readonly [number, number]> = [
130
+ [0x00, 0x20],
131
+ [0xa0, 0xa0],
132
+ [0x1680, 0x1680],
133
+ [0x180e, 0x180e],
134
+ [0x2000, 0x2029],
135
+ [0x205f, 0x205f],
136
+ [0x3000, 0x3000],
137
+ [0xfeff, 0xfeff],
138
+ ]
139
+ const escapeCodePoint = (code: number): string => "\\u" + code.toString(16).padStart(4, "0")
140
+ const URI_IGNORED_CHARS = new RegExp(
141
+ "[" +
142
+ URI_IGNORED_RANGES.map(([lo, hi]) => escapeCodePoint(lo) + "-" + escapeCodePoint(hi)).join("") +
143
+ "]",
144
+ "g",
145
+ )
146
+
147
+ function hasAllowedUriScheme(uri: string): boolean {
148
+ // No scheme → relative/anchor/query; nothing to neutralize.
149
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1]
150
+
151
+ return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase())
152
+ }
153
+
154
+ // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
155
+ // toJSON() output, so mutation is safe.
156
+ function neutralizeUnsafeUris(value: unknown): void {
157
+ if (Array.isArray(value)) {
158
+ for (const item of value) neutralizeUnsafeUris(item)
159
+ return
160
+ }
161
+
162
+ if (value === null || typeof value !== "object") return
163
+
164
+ const node = value as {
165
+ attrs?: Record<string, unknown>
166
+ marks?: unknown
167
+ content?: unknown
168
+ }
169
+
170
+ const attrs = node.attrs
171
+ if (attrs && typeof attrs.href === "string" && !hasAllowedUriScheme(attrs.href)) {
172
+ attrs.href = ""
173
+ }
174
+
175
+ neutralizeUnsafeUris(node.marks)
176
+ neutralizeUnsafeUris(node.content)
177
+ }
178
+
179
+ // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
180
+ async function resolveHtmlInput(
181
+ request: ParseDocumentRequest,
182
+ dependencies: ParseDependencies,
183
+ ): Promise<string> {
184
+ const hasHtml = request.html !== undefined
185
+ const hasMarkdown = request.markdown !== undefined
186
+ if (hasHtml === hasMarkdown) {
187
+ throw new Error("parse request must include exactly one of `html` or `markdown`")
188
+ }
189
+
190
+ if (hasMarkdown) {
191
+ const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown())
192
+ return renderMarkdown(request.markdown as string)
193
+ }
194
+
195
+ return request.html as string
196
+ }
197
+
105
198
  export async function composeDocument(
106
199
  request: RenderDocumentRequest,
107
200
  registry: DocumentRegistry,
@@ -145,20 +238,25 @@ export async function composeDocument(
145
238
  export async function parseDocument(
146
239
  request: ParseDocumentRequest,
147
240
  registry: DocumentRegistry,
148
- generateJSON?: GenerateJSON,
241
+ dependencies: ParseDependencies = {},
149
242
  ): Promise<ParseResult> {
150
- const parseHTML = generateJSON ?? (await loadGenerateJSON())
151
243
  const renderer = await resolveRenderer(request.type, registry)
152
244
  const extensions = resolveExtensions(renderer.buildExtensions(request.context))
153
245
  const schema = getSchema(extensions)
154
246
 
155
- const parsed = parseHTML(request.html, extensions)
247
+ const html = await resolveHtmlInput(request, dependencies)
248
+ const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON())
249
+ const parsed = parseHTML(html, extensions)
156
250
  const document = schema.nodeFromJSON(parsed).toJSON()
251
+ neutralizeUnsafeUris(document)
157
252
 
158
253
  return { document }
159
254
  }
160
255
 
161
- export function createParseDocument(generateJSON: GenerateJSON) {
256
+ export function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown) {
257
+ // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
258
+ const dependencies: ParseDependencies =
259
+ renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown }
162
260
  return (request: ParseDocumentRequest, registry: DocumentRegistry): Promise<ParseResult> =>
163
- parseDocument(request, registry, generateJSON)
261
+ parseDocument(request, registry, dependencies)
164
262
  }
package/src/index.ts CHANGED
@@ -12,8 +12,7 @@ export type EmailsOption =
12
12
 
13
13
  export type ReactEmailRailsOptions = {
14
14
  emails?: EmailsOption
15
- // Editor document renderers, discovered like emails. Off by default; pass `true`
16
- // to enable with defaults, or a path/options object to customize discovery.
15
+ // Editor document renderers, discovered like emails. Off by default.
17
16
  documents?: EmailsOption | boolean
18
17
  standalone?: boolean
19
18
  vite?: ReactEmailRailsViteOptions
@@ -62,17 +61,17 @@ const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/
62
61
 
63
62
  // The dedicated build environment that emits the server-side email bundle.
64
63
  export const EMAIL_ENVIRONMENT = "email"
64
+ // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
65
65
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
66
66
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
67
+ // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
67
68
  const OUT_DIR = "tmp/react-email-rails"
68
69
  const BUNDLE_FILE = "emails.js"
69
70
  const require = createRequire(import.meta.url)
70
71
 
71
- // happy-dom (pulled in by @tiptap/html when parsing) depends on `ws`, which guards
72
- // optional native-addon requires behind these env flags. A standalone (noExternal)
73
- // build would otherwise try to bundle the uninstalled `bufferutil`/`utf-8-validate`
74
- // and fail at load. Setting the flags lets Rollup tree-shake those require branches
75
- // away; ws uses its pure-JS implementation, which is all the HTML parser needs.
72
+ // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
73
+ // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
74
+ // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
76
75
  const WS_NATIVE_ADDON_OPT_OUT = {
77
76
  "process.env.WS_NO_BUFFER_UTIL": "'1'",
78
77
  "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
@@ -152,9 +151,11 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
152
151
  filter: { id: VIRTUAL_MODULE_PATTERN },
153
152
  handler(id) {
154
153
  if (id === RESOLVED_SERVER) {
155
- const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
154
+ const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`]
156
155
  const parserPeersAvailable =
157
156
  documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
157
+ // Markdown parsing layers `marked` on top of the HTML parser peers.
158
+ const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"])
158
159
 
159
160
  if (documentSource) {
160
161
  lines.push(
@@ -162,29 +163,20 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
162
163
  ? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
163
164
  : `import { composeDocument, parseDocument } from "react-email-rails/document"`,
164
165
  )
165
- if (parserPeersAvailable) lines.push(`import { generateJSON } from "@tiptap/html"`)
166
+ if (parserPeersAvailable) {
167
+ lines.push(`import { generateJSON } from "@tiptap/html"`)
168
+ if (markdownPeerAvailable) lines.push(`import { marked } from "marked"`)
169
+ }
166
170
  }
167
171
 
168
172
  lines.push(
169
- `const modules = import.meta.glob(${emailSource.globArg})`,
170
- `const extensions = ${JSON.stringify(emailSource.extensions)}`,
171
- `const registry = Object.create(null)`,
172
- `for (const path in modules) {`,
173
- ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
174
- ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
175
- `}`,
173
+ `const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`,
176
174
  )
177
175
 
178
176
  if (documentSource) {
179
177
  lines.push(
180
- `const documentModules = import.meta.glob(${documentSource.globArg})`,
181
- `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`,
182
- `const documentRegistry = Object.create(null)`,
183
- `for (const path in documentModules) {`,
184
- ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
185
- ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`,
186
- `}`,
187
- `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? "createParseDocument(generateJSON)" : "parseDocument"} })`,
178
+ `const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`,
179
+ `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`,
188
180
  )
189
181
  } else {
190
182
  lines.push(`export const run = () => serve(registry)`)
@@ -200,13 +192,9 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
200
192
  },
201
193
 
202
194
  config(_config, env: ConfigEnv) {
203
- // Register a dedicated `email` build environment. The official
204
- // react-email-rails-build bin opts into building it with an isolated
205
- // plugin stack so host app plugins cannot break email SSR builds.
206
- // The environment is a server consumer. Production standalone builds inline
207
- // Node dependencies by default so Rails runtime images do not need
208
- // node_modules; dev rendering keeps dependencies external for Vite's module
209
- // runner.
195
+ // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
196
+ // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
197
+ // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
210
198
  return {
211
199
  environments: {
212
200
  [EMAIL_ENVIRONMENT]: {
package/src/runtime.ts CHANGED
@@ -33,24 +33,24 @@ export type RenderDocumentRequest = {
33
33
  preview?: string | null
34
34
  }
35
35
 
36
+ // Exactly one of `html` or `markdown` is set.
36
37
  export type ParseDocumentRequest = {
37
38
  kind: "parse"
38
39
  type: string
39
- html: string
40
+ html?: string
41
+ markdown?: string
40
42
  context?: unknown
41
43
  }
42
44
 
43
45
  // A document node type that rendered to nothing, with how many times it occurred.
44
46
  export type DroppedNode = { type: string; count: number }
45
47
 
46
- // A render result plus any non-fatal warnings (document nodes dropped because no
47
- // extension rendered them). Component renders never carry warnings.
48
+ // A render result plus non-fatal warnings (dropped document nodes); component renders carry none.
48
49
  export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
49
50
 
50
51
  export type ParseResult = { document: unknown }
51
52
 
52
- // Injected by the generated server module when documents are enabled, so `serve`
53
- // renders documents without importing the editor module or its peer types.
53
+ // Injected by the generated server module so `serve` renders documents without importing the editor module.
54
54
  export type DocumentSupport<Registry = unknown> = {
55
55
  registry: Registry
56
56
  compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
@@ -72,6 +72,21 @@ export function toComponentName(globPath: string, root: string, extension: strin
72
72
  return globPath.slice(start, globPath.length - extension.length)
73
73
  }
74
74
 
75
+ // Map glob results to a component-name registry (used for both email and document registries).
76
+ export function buildRegistry(
77
+ modules: EmailRegistry,
78
+ extensions: string[],
79
+ root: string,
80
+ ): EmailRegistry {
81
+ const registry: EmailRegistry = Object.create(null)
82
+ for (const [path, loader] of Object.entries(modules)) {
83
+ const extension =
84
+ extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."))
85
+ registry[toComponentName(path, root, extension)] = loader
86
+ }
87
+ return registry
88
+ }
89
+
75
90
  export async function renderEmail(
76
91
  request: RenderRequest,
77
92
  registry: EmailRegistry,
@@ -93,15 +108,14 @@ function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
93
108
  return (
94
109
  request !== null &&
95
110
  typeof request === "object" &&
96
- (request as { kind?: unknown }).kind === "document"
111
+ "kind" in request &&
112
+ request.kind === "document"
97
113
  )
98
114
  }
99
115
 
100
116
  function isParseRequest(request: unknown): request is ParseDocumentRequest {
101
117
  return (
102
- request !== null &&
103
- typeof request === "object" &&
104
- (request as { kind?: unknown }).kind === "parse"
118
+ request !== null && typeof request === "object" && "kind" in request && request.kind === "parse"
105
119
  )
106
120
  }
107
121
 
@@ -159,14 +173,16 @@ export async function serve<Registry = unknown>(
159
173
  }
160
174
  }
161
175
 
162
- // Reserve stdout for the JSON render protocol. Stray writes from email components
163
- // or their dependencies including console.log, which Node routes through
164
- // process.stdout.write are diverted to stderr so they cannot corrupt or desync
165
- // a response frame. Returns the writer to use for protocol output.
176
+ // Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
177
+ // routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
178
+ // Returns the writer to use for protocol output.
166
179
  function isolateStdout(): (chunk: string) => boolean {
167
180
  const protocolWrite = process.stdout.write.bind(process.stdout)
168
- process.stdout.write = ((chunk: string | Uint8Array): boolean =>
169
- process.stderr.write(chunk)) as typeof process.stdout.write
181
+ // Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
182
+ process.stdout.write = ((chunk, encoding, callback) =>
183
+ typeof encoding === "function"
184
+ ? process.stderr.write(chunk, encoding)
185
+ : process.stderr.write(chunk, encoding, callback)) as typeof process.stdout.write
170
186
  return (chunk) => protocolWrite(chunk)
171
187
  }
172
188
 
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.3.0"
1
+ export const VERSION = "0.4.1"
2
2
  export const RENDER_PROTOCOL_VERSION = 3