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 +9 -11
- package/bin/dev.mjs +10 -13
- package/bin/shared.mjs +14 -0
- package/dist/document.d.ts +9 -17
- package/dist/document.js +80 -8
- package/dist/index.js +17 -16
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +18 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
- package/src/document.ts +122 -24
- package/src/index.ts +19 -31
- package/src/runtime.ts +31 -15
- package/src/version.ts +1 -1
package/bin/build.mjs
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createBuilder } from "vite"
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
exitIfHealthCheck,
|
|
5
|
+
fail,
|
|
6
|
+
isolatedViteConfig,
|
|
7
|
+
loadReactEmailRailsConfig,
|
|
8
|
+
} from "./shared.mjs"
|
|
5
9
|
|
|
6
|
-
|
|
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
|
-
//
|
|
56
|
-
//
|
|
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 {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
exitIfHealthCheck,
|
|
5
|
+
fail,
|
|
6
|
+
isolatedViteConfig,
|
|
7
|
+
loadReactEmailRailsConfig,
|
|
8
|
+
} from "./shared.mjs"
|
|
5
9
|
|
|
6
|
-
|
|
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
|
|
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
|
|
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,
|
package/dist/document.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
11
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
|
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
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
"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 {
|
|
7
|
+
import type {
|
|
8
|
+
DroppedNode,
|
|
9
|
+
ParseDocumentRequest,
|
|
10
|
+
ParseResult,
|
|
11
|
+
RenderDocumentRequest,
|
|
12
|
+
RenderResult,
|
|
13
|
+
} from "./runtime.js"
|
|
8
14
|
|
|
9
|
-
|
|
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
|
|
26
|
-
//
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
|
181
|
-
`const
|
|
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
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
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.
|
|
1
|
+
export const VERSION = "0.4.1"
|
|
2
2
|
export const RENDER_PROTOCOL_VERSION = 3
|