toiljs 0.0.44 → 0.0.46
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/CHANGELOG.md +9 -0
- package/RSG.md +105 -27
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/email-preview.d.ts +12 -0
- package/build/compiler/email-preview.js +253 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +52 -12
- package/build/compiler/index.js +15 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/dotenv.d.ts +8 -0
- package/build/devserver/dotenv.js +59 -0
- package/build/devserver/email/caps.d.ts +9 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.d.ts +21 -0
- package/build/devserver/email/config.js +72 -0
- package/build/devserver/email/index.d.ts +25 -0
- package/build/devserver/email/index.js +57 -0
- package/build/devserver/email/providers.d.ts +12 -0
- package/build/devserver/email/providers.js +96 -0
- package/build/devserver/email/status.d.ts +10 -0
- package/build/devserver/email/status.js +11 -0
- package/build/devserver/email/validate.d.ts +2 -0
- package/build/devserver/email/validate.js +24 -0
- package/build/devserver/email/wire.d.ts +8 -0
- package/build/devserver/email/wire.js +32 -0
- package/build/devserver/env.js +5 -54
- package/build/devserver/host.js +22 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/email.md +64 -22
- package/package.json +4 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/config.ts +14 -0
- package/src/compiler/email-preview.ts +305 -0
- package/src/compiler/emails.ts +82 -12
- package/src/compiler/index.ts +20 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/src/devserver/dotenv.ts +94 -0
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +123 -0
- package/src/devserver/email/index.ts +111 -0
- package/src/devserver/email/providers.ts +130 -0
- package/src/devserver/email/status.ts +23 -0
- package/src/devserver/email/validate.ts +40 -0
- package/src/devserver/email/wire.ts +55 -0
- package/src/devserver/env.ts +8 -65
- package/src/devserver/host.ts +29 -12
- package/src/devserver/index.ts +20 -0
- package/src/shared/index.ts +36 -0
- package/test/devserver-email.test.ts +241 -0
- package/test/email-preview.test.ts +68 -0
- package/test/emails.test.ts +58 -0
package/src/cli/create.ts
CHANGED
|
@@ -156,14 +156,14 @@ function scaffold(
|
|
|
156
156
|
' "compilerOptions": {\n' +
|
|
157
157
|
' "paths": { "shared/*": ["./shared/*"] }\n' +
|
|
158
158
|
' },\n' +
|
|
159
|
-
' "include": ["client", "shared", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
|
|
159
|
+
' "include": ["client", "shared", "emails", "toil.config.ts", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
|
|
160
160
|
'}\n',
|
|
161
161
|
'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
|
|
162
162
|
'.prettierrc': '"toiljs/prettier"\n',
|
|
163
163
|
// Generated files don't need formatting. (toilscript server decorators like @main /
|
|
164
164
|
// @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
|
|
165
165
|
'.prettierignore':
|
|
166
|
-
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
166
|
+
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/toil-server-env.d.ts\n',
|
|
167
167
|
'.gitignore':
|
|
168
168
|
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
|
|
169
169
|
// Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
|
package/src/cli/doctor.ts
CHANGED
|
@@ -296,9 +296,24 @@ function applyRpcFix(root: string): RpcFixResult {
|
|
|
296
296
|
// and synthesizing one would narrow what TypeScript sees.
|
|
297
297
|
if (Array.isArray(tsconfig.include)) {
|
|
298
298
|
const include = [...(tsconfig.include as unknown[])];
|
|
299
|
+
let changedInclude = false;
|
|
300
|
+
// `shared` (the @data/@remote RPC alias target) right after `client`.
|
|
299
301
|
if (!include.includes('shared')) {
|
|
300
302
|
const at = include.indexOf('client');
|
|
301
303
|
include.splice(at >= 0 ? at + 1 : include.length, 0, 'shared');
|
|
304
|
+
changedInclude = true;
|
|
305
|
+
}
|
|
306
|
+
// `emails` (the React email-template pipeline) + `toil.config.ts`, so the
|
|
307
|
+
// typescript-eslint project service / editor cover them — otherwise
|
|
308
|
+
// `emails/*.tsx` (and a typed `toil.config.ts`) raise "not found by the
|
|
309
|
+
// project service". Harmless when absent (a non-matching glob).
|
|
310
|
+
for (const entry of ['emails', 'toil.config.ts']) {
|
|
311
|
+
if (!include.includes(entry)) {
|
|
312
|
+
include.push(entry);
|
|
313
|
+
changedInclude = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (changedInclude) {
|
|
302
317
|
tsconfig.include = include;
|
|
303
318
|
touched = true;
|
|
304
319
|
}
|
package/src/compiler/config.ts
CHANGED
|
@@ -3,10 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import { type InlineConfig } from 'vite';
|
|
6
|
+
import { type EmailBackendConfig } from 'toiljs/shared';
|
|
6
7
|
|
|
7
8
|
import { type SeoConfig } from './seo.js';
|
|
8
9
|
|
|
9
10
|
export type { SeoConfig } from './seo.js';
|
|
11
|
+
export type { EmailBackendConfig, SmtpBackendConfig } from 'toiljs/shared';
|
|
10
12
|
|
|
11
13
|
/** Built-in AI providers the dev toolbar can proxy to. */
|
|
12
14
|
export enum AiProvider {
|
|
@@ -103,6 +105,15 @@ export interface ServerConfig {
|
|
|
103
105
|
readonly srcDir?: string;
|
|
104
106
|
/** Server build output directory, relative to root. Default `build/server`. */
|
|
105
107
|
readonly outDir?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Email backend config (the dev server and the future Node self-host). The
|
|
110
|
+
* non-secret pieces — provider, `from`, send caps, SMTP host/port/user. The
|
|
111
|
+
* API key / SMTP password is a SECRET and lives ONLY in `.env.secrets`
|
|
112
|
+
* (`TOIL_EMAIL_API_KEY`); any `TOIL_EMAIL_*` env var overrides the matching
|
|
113
|
+
* field here. The production edge ignores this (it reads `TOIL_EMAIL_*` from
|
|
114
|
+
* the per-tenant env store); this drives `toiljs dev` / self-host.
|
|
115
|
+
*/
|
|
116
|
+
readonly email?: EmailBackendConfig;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
/**
|
|
@@ -144,6 +155,8 @@ export interface ResolvedToilConfig {
|
|
|
144
155
|
readonly devtoolsAi: DevtoolsAiConfig | null;
|
|
145
156
|
/** Build-time SEO config, or `null` when not configured. */
|
|
146
157
|
readonly seo: SeoConfig | null;
|
|
158
|
+
/** The `server.email` backend config (dev / self-host), or `null` when unset. */
|
|
159
|
+
readonly email: EmailBackendConfig | null;
|
|
147
160
|
/** Absolute path to the framework client runtime (`toiljs/client`). */
|
|
148
161
|
readonly runtimePath: string;
|
|
149
162
|
readonly vite: InlineConfig;
|
|
@@ -215,6 +228,7 @@ export async function loadConfig(
|
|
|
215
228
|
? (client.devtools.ai ?? null)
|
|
216
229
|
: null,
|
|
217
230
|
seo: client.seo ?? null,
|
|
231
|
+
email: user.server?.email ?? null,
|
|
218
232
|
runtimePath: resolveRuntimePath(),
|
|
219
233
|
vite: client.vite ?? {},
|
|
220
234
|
};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only email preview tool. Backs the `/__toil/emails*` endpoints (wired in
|
|
3
|
+
* `plugin.ts`): a standalone page that lists `emails/*.tsx`, renders the selected
|
|
4
|
+
* one through the live Vite SSR server (so edits and imported `client/*` CSS show
|
|
5
|
+
* up), and fills `{{token}}` holes from inputs the same way the edge does at send
|
|
6
|
+
* time. Build-path parity comes from sharing `renderEmailFile` with the codegen
|
|
7
|
+
* pass (`emails.ts`), so what you preview is what gets baked into `server/_emails.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import type { ViteDevServer } from 'vite';
|
|
14
|
+
|
|
15
|
+
import type { ResolvedToilConfig } from './config.js';
|
|
16
|
+
import { renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
|
|
17
|
+
|
|
18
|
+
/** One discoverable email: its generated `Emails.<name>` and its absolute file. */
|
|
19
|
+
export interface EmailListItem {
|
|
20
|
+
name: string;
|
|
21
|
+
/** Absolute path, used for "open in editor" (`/__toil/open?file=`). */
|
|
22
|
+
file: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `emails/` dir for a project (sibling of `client/` and `server/`). */
|
|
26
|
+
export function emailsDir(cfg: ResolvedToilConfig): string {
|
|
27
|
+
return path.join(cfg.root, 'emails');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A cheap change fingerprint (`<newestMtime>:<fileCount>`) over `emails/*.tsx|jsx`
|
|
32
|
+
* and the project's client CSS, polled by the preview page to detect edits (any
|
|
33
|
+
* save bumps an mtime to ~now; add/remove changes the count). Stat-only, so it is
|
|
34
|
+
* fine to poll ~1/s. Used instead of a long-lived stream, which the buffering wasm
|
|
35
|
+
* dev proxy can't forward.
|
|
36
|
+
*/
|
|
37
|
+
export function emailsVersion(cfg: ResolvedToilConfig): string {
|
|
38
|
+
let newest = 0;
|
|
39
|
+
let count = 0;
|
|
40
|
+
const CSS = /\.(css|scss|sass|less|styl|pcss|postcss)$/;
|
|
41
|
+
const walk = (dir: string, match: RegExp): void => {
|
|
42
|
+
let entries: fs.Dirent[];
|
|
43
|
+
try {
|
|
44
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
const full = path.join(dir, e.name);
|
|
50
|
+
if (e.isDirectory()) {
|
|
51
|
+
if (e.name !== 'node_modules') walk(full, match);
|
|
52
|
+
} else if (match.test(e.name)) {
|
|
53
|
+
try {
|
|
54
|
+
const m = fs.statSync(full).mtimeMs;
|
|
55
|
+
if (m > newest) newest = m;
|
|
56
|
+
count++;
|
|
57
|
+
} catch {
|
|
58
|
+
// file vanished between readdir and stat; ignore
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
// Email templates and any styles beside them (emails/styles/*), plus client
|
|
64
|
+
// CSS in case an email reuses `client/styles/*`.
|
|
65
|
+
walk(emailsDir(cfg), /\.(tsx|jsx|css|scss|sass|less|styl|pcss|postcss)$/);
|
|
66
|
+
walk(cfg.clientAbsDir, CSS);
|
|
67
|
+
return `${String(newest)}:${String(count)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List `emails/*.tsx|jsx`, mapped to their generated names. Cheap (no render). */
|
|
71
|
+
export function listEmails(cfg: ResolvedToilConfig): EmailListItem[] {
|
|
72
|
+
const dir = emailsDir(cfg);
|
|
73
|
+
if (!fs.existsSync(dir)) return [];
|
|
74
|
+
return fs
|
|
75
|
+
.readdirSync(dir)
|
|
76
|
+
.filter((f) => /\.(tsx|jsx)$/.test(f))
|
|
77
|
+
.sort()
|
|
78
|
+
.map((f) => ({
|
|
79
|
+
name: toPascal(path.basename(f).replace(/\.(tsx|jsx)$/, '')),
|
|
80
|
+
file: path.join(dir, f),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render the email whose generated name is `name` through the live SSR server,
|
|
86
|
+
* or `null` if there is no such file. Drops the module from the SSR cache first
|
|
87
|
+
* so an edit is reflected on every request (the watcher also invalidates on save;
|
|
88
|
+
* this makes a manual refresh fresh too).
|
|
89
|
+
*/
|
|
90
|
+
export async function renderEmailByName(
|
|
91
|
+
server: ViteDevServer,
|
|
92
|
+
cfg: ResolvedToilConfig,
|
|
93
|
+
name: string,
|
|
94
|
+
): Promise<RenderedEmail | null> {
|
|
95
|
+
const item = listEmails(cfg).find((e) => e.name === name);
|
|
96
|
+
if (!item) return null;
|
|
97
|
+
const node =
|
|
98
|
+
server.moduleGraph.getModuleById(item.file) ??
|
|
99
|
+
(await server.moduleGraph.getModuleByUrl(item.file));
|
|
100
|
+
if (node) server.moduleGraph.invalidateModule(node);
|
|
101
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
102
|
+
return renderEmailFile(
|
|
103
|
+
server,
|
|
104
|
+
emailsDir(cfg),
|
|
105
|
+
path.basename(item.file),
|
|
106
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The self-contained preview page (served at `/__toil/emails`). Plain HTML + a
|
|
112
|
+
* tiny inline script -- no client-runtime dependency, so it works in both the
|
|
113
|
+
* client-only and wasm-server dev modes. Token substitution happens here in the
|
|
114
|
+
* browser (`{{token}}` -> input value), so typing is instant and the iframe shows
|
|
115
|
+
* exactly the edge's hole-fill path.
|
|
116
|
+
*/
|
|
117
|
+
export function previewShellHtml(): string {
|
|
118
|
+
return `<!doctype html>
|
|
119
|
+
<html lang="en">
|
|
120
|
+
<head>
|
|
121
|
+
<meta charset="utf-8" />
|
|
122
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
123
|
+
<title>Email preview · toiljs</title>
|
|
124
|
+
<style>
|
|
125
|
+
:root { color-scheme: dark; }
|
|
126
|
+
* { box-sizing: border-box; }
|
|
127
|
+
body { margin: 0; height: 100vh; display: flex; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #0c0c11; color: #e7e9f0; }
|
|
128
|
+
#side { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; display: flex; flex-direction: column; background: #101016; }
|
|
129
|
+
.brand { padding: 14px 16px; font-weight: 600; border-bottom: 1px solid #23232e; }
|
|
130
|
+
#list { list-style: none; margin: 0; padding: 6px; overflow: auto; flex: 1; }
|
|
131
|
+
#list li { padding: 8px 10px; border-radius: 8px; cursor: pointer; color: #c8cee0; }
|
|
132
|
+
#list li:hover { background: #181820; }
|
|
133
|
+
#list li.on { background: #1d1d6b33; color: #fff; }
|
|
134
|
+
#list li.muted { color: #6b7080; cursor: default; }
|
|
135
|
+
.hint { padding: 10px 16px; font-size: 12px; color: #6b7080; border-top: 1px solid #23232e; }
|
|
136
|
+
.hint code { color: #9aa1b8; }
|
|
137
|
+
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
138
|
+
.empty { margin: auto; color: #6b7080; }
|
|
139
|
+
#view { display: flex; flex-direction: column; height: 100%; }
|
|
140
|
+
.bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #23232e; }
|
|
141
|
+
.subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 8px; }
|
|
142
|
+
.subj .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #6b7080; }
|
|
143
|
+
#subject { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
144
|
+
.actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
|
145
|
+
.seg { display: flex; border: 1px solid #2c2c38; border-radius: 8px; overflow: hidden; }
|
|
146
|
+
.seg-btn { background: #15151c; border: 0; color: #8b90a4; font: inherit; padding: 6px 12px; cursor: pointer; }
|
|
147
|
+
.seg-btn.on { background: #2563ff; color: #fff; }
|
|
148
|
+
.btn { background: #15151c; border: 1px solid #2c2c38; color: #c8cee0; font: inherit; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
|
|
149
|
+
.btn:hover { color: #fff; border-color: #3a3a48; }
|
|
150
|
+
.body { display: flex; flex: 1; min-height: 0; }
|
|
151
|
+
.tokens { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; padding: 12px; overflow: auto; }
|
|
152
|
+
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
153
|
+
.fname { font-size: 12px; color: #9aa1b8; }
|
|
154
|
+
.field input { background: #0c0c11; border: 1px solid #2c2c38; border-radius: 6px; color: #e7e9f0; font: inherit; padding: 6px 8px; }
|
|
155
|
+
.field input:focus { outline: none; border-color: #2563ff; }
|
|
156
|
+
.muted { color: #6b7080; font-size: 12px; }
|
|
157
|
+
.preview { flex: 1; min-width: 0; background: #f6f7f9; }
|
|
158
|
+
#frame { width: 100%; height: 100%; border: 0; background: #fff; }
|
|
159
|
+
#text { width: 100%; height: 100%; margin: 0; padding: 16px; overflow: auto; background: #0c0c11; color: #c8cee0; white-space: pre-wrap; }
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<aside id="side">
|
|
164
|
+
<div class="brand">✉ Emails</div>
|
|
165
|
+
<ul id="list"></ul>
|
|
166
|
+
<div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
|
|
167
|
+
</aside>
|
|
168
|
+
<main id="main">
|
|
169
|
+
<div id="empty" class="empty">Select an email to preview.</div>
|
|
170
|
+
<section id="view" hidden>
|
|
171
|
+
<header class="bar">
|
|
172
|
+
<div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
|
|
173
|
+
<div class="actions">
|
|
174
|
+
<div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
|
|
175
|
+
<button id="open" class="btn">Open in editor</button>
|
|
176
|
+
</div>
|
|
177
|
+
</header>
|
|
178
|
+
<div class="body">
|
|
179
|
+
<div class="tokens" id="tokens"></div>
|
|
180
|
+
<div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
</main>
|
|
184
|
+
<script>
|
|
185
|
+
(function () {
|
|
186
|
+
var BASE = '/__toil/emails';
|
|
187
|
+
var listEl = document.getElementById('list');
|
|
188
|
+
var subjectEl = document.getElementById('subject');
|
|
189
|
+
var frame = document.getElementById('frame');
|
|
190
|
+
var textEl = document.getElementById('text');
|
|
191
|
+
var tokensEl = document.getElementById('tokens');
|
|
192
|
+
var emptyEl = document.getElementById('empty');
|
|
193
|
+
var viewEl = document.getElementById('view');
|
|
194
|
+
var tabHtml = document.getElementById('tab-html');
|
|
195
|
+
var tabText = document.getElementById('tab-text');
|
|
196
|
+
var openBtn = document.getElementById('open');
|
|
197
|
+
var current = null, rendered = null, format = 'html', values = {};
|
|
198
|
+
|
|
199
|
+
function fill(s) {
|
|
200
|
+
return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
|
|
201
|
+
return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function paint() {
|
|
205
|
+
if (!rendered) return;
|
|
206
|
+
subjectEl.textContent = fill(rendered.subject);
|
|
207
|
+
if (format === 'html') {
|
|
208
|
+
frame.hidden = false; textEl.hidden = true;
|
|
209
|
+
frame.srcdoc = fill(rendered.html);
|
|
210
|
+
} else {
|
|
211
|
+
frame.hidden = true; textEl.hidden = false;
|
|
212
|
+
textEl.textContent = fill(rendered.text);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function paintTokens() {
|
|
216
|
+
tokensEl.textContent = '';
|
|
217
|
+
if (!rendered.tokens.length) {
|
|
218
|
+
var none = document.createElement('div');
|
|
219
|
+
none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
|
|
220
|
+
tokensEl.appendChild(none); return;
|
|
221
|
+
}
|
|
222
|
+
rendered.tokens.forEach(function (t) {
|
|
223
|
+
var row = document.createElement('label'); row.className = 'field';
|
|
224
|
+
var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
|
|
225
|
+
var inp = document.createElement('input');
|
|
226
|
+
inp.value = values[t] != null ? values[t] : t;
|
|
227
|
+
values[t] = inp.value;
|
|
228
|
+
inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
|
|
229
|
+
row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function setFormat(f) {
|
|
233
|
+
format = f;
|
|
234
|
+
tabHtml.classList.toggle('on', f === 'html');
|
|
235
|
+
tabText.classList.toggle('on', f === 'text');
|
|
236
|
+
paint();
|
|
237
|
+
}
|
|
238
|
+
tabHtml.addEventListener('click', function () { setFormat('html'); });
|
|
239
|
+
tabText.addEventListener('click', function () { setFormat('text'); });
|
|
240
|
+
openBtn.addEventListener('click', function () {
|
|
241
|
+
if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
function select(item, keep) {
|
|
245
|
+
current = item;
|
|
246
|
+
Array.prototype.forEach.call(listEl.children, function (li) {
|
|
247
|
+
li.classList.toggle('on', li.getAttribute('data-name') === item.name);
|
|
248
|
+
});
|
|
249
|
+
fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
|
|
250
|
+
if (!r.ok) throw new Error('render failed');
|
|
251
|
+
return r.json();
|
|
252
|
+
}).then(function (data) {
|
|
253
|
+
rendered = data;
|
|
254
|
+
if (!keep) values = {};
|
|
255
|
+
emptyEl.hidden = true; viewEl.hidden = false;
|
|
256
|
+
paintTokens(); paint();
|
|
257
|
+
}).catch(function () {
|
|
258
|
+
emptyEl.hidden = false; viewEl.hidden = true;
|
|
259
|
+
emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function buildList(items) {
|
|
263
|
+
listEl.textContent = '';
|
|
264
|
+
if (!items.length) {
|
|
265
|
+
var li = document.createElement('li');
|
|
266
|
+
li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
|
|
267
|
+
listEl.appendChild(li); return;
|
|
268
|
+
}
|
|
269
|
+
items.forEach(function (it) {
|
|
270
|
+
var li = document.createElement('li');
|
|
271
|
+
li.setAttribute('data-name', it.name);
|
|
272
|
+
li.textContent = it.name;
|
|
273
|
+
li.classList.toggle('on', !!current && it.name === current.name);
|
|
274
|
+
li.addEventListener('click', function () { select(it, false); });
|
|
275
|
+
listEl.appendChild(li);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function refresh() {
|
|
279
|
+
fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
|
|
280
|
+
buildList(items);
|
|
281
|
+
if (!items.length) {
|
|
282
|
+
current = null; rendered = null;
|
|
283
|
+
emptyEl.hidden = false; viewEl.hidden = true;
|
|
284
|
+
emptyEl.textContent = 'No emails/*.tsx found.';
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
var match = current && items.filter(function (it) { return it.name === current.name; })[0];
|
|
288
|
+
select(match || items[0], !!match);
|
|
289
|
+
}).catch(function () {});
|
|
290
|
+
}
|
|
291
|
+
refresh();
|
|
292
|
+
// Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
|
|
293
|
+
var version = null;
|
|
294
|
+
setInterval(function () {
|
|
295
|
+
fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
|
|
296
|
+
if (version === null) { version = v; return; }
|
|
297
|
+
if (v !== version) { version = v; refresh(); }
|
|
298
|
+
}).catch(function () {});
|
|
299
|
+
}, 1000);
|
|
300
|
+
})();
|
|
301
|
+
</script>
|
|
302
|
+
</body>
|
|
303
|
+
</html>
|
|
304
|
+
`;
|
|
305
|
+
}
|
package/src/compiler/emails.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
|
-
import { createServer } from 'vite';
|
|
24
|
+
import { createServer, type ViteDevServer } from 'vite';
|
|
25
25
|
|
|
26
26
|
import type { ResolvedToilConfig } from './config.js';
|
|
27
27
|
import { createViteConfig } from './vite.js';
|
|
@@ -38,7 +38,7 @@ interface EmailModule {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** One email rendered to its baked, token-holed parts. */
|
|
41
|
-
interface RenderedEmail {
|
|
41
|
+
export interface RenderedEmail {
|
|
42
42
|
name: string;
|
|
43
43
|
subject: string;
|
|
44
44
|
html: string;
|
|
@@ -132,13 +132,17 @@ async function renderModule(
|
|
|
132
132
|
name: string,
|
|
133
133
|
mod: EmailModule,
|
|
134
134
|
render: (el: unknown) => string,
|
|
135
|
+
css = '',
|
|
135
136
|
): Promise<RenderedEmail | null> {
|
|
136
137
|
if (typeof mod.default !== 'function') return null;
|
|
137
138
|
|
|
138
139
|
const seen = new Set<string>();
|
|
139
140
|
const component = mod.default as (props: unknown) => unknown;
|
|
140
141
|
let html = render(component(tokenProps(seen)));
|
|
141
|
-
|
|
142
|
+
// CSS the component imported (e.g. `import 'client/styles/email.css'`) is
|
|
143
|
+
// prepended as a <style> block so it gets inlined into element style="" like
|
|
144
|
+
// an inline block would -- under SSR a bare CSS import otherwise has no effect.
|
|
145
|
+
html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
|
|
142
146
|
|
|
143
147
|
const subject = typeof mod.subject === 'string' ? mod.subject : name;
|
|
144
148
|
const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
|
|
@@ -161,8 +165,71 @@ async function renderModule(
|
|
|
161
165
|
return { name, subject, html, text, tokens, purpose };
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collect the CSS an email module transitively imports, as one string. Under SSR
|
|
172
|
+
* a bare `import 'client/styles/email.css'` produces no output, so we walk the
|
|
173
|
+
* Vite module graph from the email module, collect its CSS deps, and re-import
|
|
174
|
+
* each with `?inline` (Vite then returns the processed CSS as the default export,
|
|
175
|
+
* Tailwind/PostCSS included). The caller hands the result to `renderModule`,
|
|
176
|
+
* which inlines it into the HTML. Best-effort: a CSS dep that can't be inlined is
|
|
177
|
+
* skipped (the component's inline `style={{}}` props still render).
|
|
178
|
+
*/
|
|
179
|
+
export async function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string> {
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
const cssIds = new Set<string>();
|
|
182
|
+
const visit = (id: string): void => {
|
|
183
|
+
if (seen.has(id)) return;
|
|
184
|
+
seen.add(id);
|
|
185
|
+
const mod = server.moduleGraph.getModuleById(id);
|
|
186
|
+
if (!mod) return;
|
|
187
|
+
for (const dep of mod.importedModules) {
|
|
188
|
+
const depId = dep.id ?? dep.url;
|
|
189
|
+
if (!depId) continue;
|
|
190
|
+
if (CSS_RE.test(depId)) cssIds.add(depId);
|
|
191
|
+
else visit(depId);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
visit(moduleId);
|
|
195
|
+
|
|
196
|
+
let css = '';
|
|
197
|
+
for (const id of cssIds) {
|
|
198
|
+
const base = id.split('?')[0] ?? id;
|
|
199
|
+
try {
|
|
200
|
+
const mod = (await server.ssrLoadModule(`${base}?inline`)) as { default?: unknown };
|
|
201
|
+
if (typeof mod.default === 'string') css += mod.default + '\n';
|
|
202
|
+
} catch {
|
|
203
|
+
// skip a CSS dep we can't inline
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return css;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load one `emails/*.tsx` through `server` (SSR), collect any CSS it imports, and
|
|
211
|
+
* render it to its baked, token-holed parts. Shared by the build/codegen pass and
|
|
212
|
+
* the dev preview tool so both produce byte-identical output. Throws if the module
|
|
213
|
+
* fails to load; returns `null` if it has no default-exported component.
|
|
214
|
+
*/
|
|
215
|
+
export async function renderEmailFile(
|
|
216
|
+
server: ViteDevServer,
|
|
217
|
+
emailsDir: string,
|
|
218
|
+
file: string,
|
|
219
|
+
render: (el: unknown) => string,
|
|
220
|
+
): Promise<RenderedEmail | null> {
|
|
221
|
+
const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
|
|
222
|
+
const filePath = path.join(emailsDir, file);
|
|
223
|
+
const mod = (await server.ssrLoadModule(filePath)) as EmailModule;
|
|
224
|
+
const node =
|
|
225
|
+
server.moduleGraph.getModuleById(filePath) ??
|
|
226
|
+
(await server.moduleGraph.getModuleByUrl(filePath));
|
|
227
|
+
const css = node?.id ? await collectModuleCss(server, node.id) : '';
|
|
228
|
+
return renderModule(name, mod, render, css);
|
|
229
|
+
}
|
|
230
|
+
|
|
164
231
|
/** `welcome-email` / `welcome_email` -> `WelcomeEmail`. */
|
|
165
|
-
function toPascal(base: string): string {
|
|
232
|
+
export function toPascal(base: string): string {
|
|
166
233
|
return base
|
|
167
234
|
.split(/[-_\s.]+/)
|
|
168
235
|
.filter(Boolean)
|
|
@@ -222,7 +289,9 @@ function renderModuleSource(rendered: RenderedEmail[]): string {
|
|
|
222
289
|
.concat(params.map((p) => `${p.param}: string`))
|
|
223
290
|
.concat(`purpose: string = ${asLit(e.purpose)}`)
|
|
224
291
|
.join(', ');
|
|
225
|
-
out.push(
|
|
292
|
+
out.push(
|
|
293
|
+
` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`,
|
|
294
|
+
);
|
|
226
295
|
out.push(` export function send(${sig}): EmailStatus {`);
|
|
227
296
|
out.push(` const __v = new Map<string, string>();`);
|
|
228
297
|
for (const p of params) out.push(` __v.set(${asLit(p.token)}, ${p.param});`);
|
|
@@ -274,17 +343,18 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
274
343
|
const rendered: RenderedEmail[] = [];
|
|
275
344
|
try {
|
|
276
345
|
for (const file of files) {
|
|
277
|
-
const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
|
|
278
|
-
let mod: EmailModule;
|
|
279
346
|
try {
|
|
280
|
-
|
|
347
|
+
const r = await renderEmailFile(
|
|
348
|
+
server,
|
|
349
|
+
emailsDir,
|
|
350
|
+
file,
|
|
351
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
352
|
+
);
|
|
353
|
+
if (r) rendered.push(r);
|
|
354
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
281
355
|
} catch (err) {
|
|
282
356
|
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
283
|
-
continue;
|
|
284
357
|
}
|
|
285
|
-
const r = await renderModule(name, mod, renderToStaticMarkup as (el: unknown) => string);
|
|
286
|
-
if (r) rendered.push(r);
|
|
287
|
-
else warn(`skipped ${file} (no default-exported component)`);
|
|
288
358
|
}
|
|
289
359
|
} finally {
|
|
290
360
|
await server.close();
|
package/src/compiler/index.ts
CHANGED
|
@@ -268,6 +268,23 @@ export interface ToilCommandOptions {
|
|
|
268
268
|
readonly serverOnly?: boolean;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/** Prints the email-preview URL under the dev banner, when the project has an
|
|
272
|
+
* `emails/` folder. `localUrl` is the resolved base (ends in `/`); skipped if
|
|
273
|
+
* the server didn't report one. */
|
|
274
|
+
function printEmailsUrl(cfg: ResolvedToilConfig, localUrl: string | undefined): void {
|
|
275
|
+
if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails'))) return;
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
' ' +
|
|
278
|
+
pc.green('✉') +
|
|
279
|
+
' ' +
|
|
280
|
+
pc.bold('Emails') +
|
|
281
|
+
': ' +
|
|
282
|
+
pc.cyan(`${localUrl}__toil/emails`) +
|
|
283
|
+
pc.dim(' (preview)') +
|
|
284
|
+
'\n',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
271
288
|
/**
|
|
272
289
|
* Starts the dev server. Client-only projects get the plain Vite dev server on
|
|
273
290
|
* the configured port, unchanged. Projects with a server target
|
|
@@ -293,6 +310,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
293
310
|
const server = await createServer(await createViteConfig(cfg));
|
|
294
311
|
await server.listen();
|
|
295
312
|
server.printUrls();
|
|
313
|
+
printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
|
|
296
314
|
return server;
|
|
297
315
|
}
|
|
298
316
|
|
|
@@ -310,6 +328,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
310
328
|
port: cfg.port,
|
|
311
329
|
wasmFile: serverWasmFile(cfg.root),
|
|
312
330
|
vite: { host: '127.0.0.1', port: vitePort },
|
|
331
|
+
email: cfg.email ?? undefined,
|
|
313
332
|
});
|
|
314
333
|
server.httpServer?.once('close', () => {
|
|
315
334
|
void front.close();
|
|
@@ -324,6 +343,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
324
343
|
pc.dim(' (wasm server + vite)') +
|
|
325
344
|
'\n',
|
|
326
345
|
);
|
|
346
|
+
printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
|
|
327
347
|
|
|
328
348
|
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
329
349
|
// and the dev server hot-swaps the recompiled wasm module.
|