toiljs 0.0.45 → 0.0.47
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 +20 -0
- package/RSG.md +21 -8
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/email-preview.d.ts +12 -0
- package/build/compiler/email-preview.js +260 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +58 -15
- package/build/compiler/index.js +37 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/docs/email.md +35 -19
- package/package.json +2 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/email-preview.ts +312 -0
- package/src/compiler/emails.ts +90 -15
- package/src/compiler/index.ts +51 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/test/email-preview.test.ts +68 -0
- package/test/emails.test.ts +58 -0
package/docs/email.md
CHANGED
|
@@ -109,6 +109,7 @@ export default defineConfig({
|
|
|
109
109
|
},
|
|
110
110
|
});
|
|
111
111
|
```
|
|
112
|
+
|
|
112
113
|
```bash
|
|
113
114
|
# .env.secrets (gitignored)
|
|
114
115
|
TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx
|
|
@@ -137,8 +138,8 @@ class Notify {
|
|
|
137
138
|
const status = EmailService.send(
|
|
138
139
|
'alice@example.com',
|
|
139
140
|
'Welcome!',
|
|
140
|
-
'Thanks for signing up.',
|
|
141
|
-
'welcome',
|
|
141
|
+
'Thanks for signing up.', // plain-text body
|
|
142
|
+
'welcome', // purpose tag (dedup / abuse keying)
|
|
142
143
|
'<h1>Thanks for signing up.</h1>', // optional HTML body
|
|
143
144
|
);
|
|
144
145
|
return status == EmailStatus.Sent
|
|
@@ -150,16 +151,16 @@ class Notify {
|
|
|
150
151
|
|
|
151
152
|
`send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:
|
|
152
153
|
|
|
153
|
-
| Status
|
|
154
|
-
|
|
|
155
|
-
| `Sent`
|
|
156
|
-
| `Deduped`
|
|
157
|
-
| `Budget`
|
|
158
|
-
| `TryLater`
|
|
159
|
-
| `RecipientCapped` | The per-recipient hourly cap was hit
|
|
160
|
-
| `BadRecipient`
|
|
161
|
-
| `Disabled`
|
|
162
|
-
| `ProviderError`
|
|
154
|
+
| Status | Meaning | Retry? |
|
|
155
|
+
| ----------------- | ----------------------------------------------------------- | ---------------- |
|
|
156
|
+
| `Sent` | Accepted by the provider | — |
|
|
157
|
+
| `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |
|
|
158
|
+
| `Budget` | The host's per-minute budget is exhausted | yes, later |
|
|
159
|
+
| `TryLater` | The mailer was saturated / a queue was full | yes, back off |
|
|
160
|
+
| `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |
|
|
161
|
+
| `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |
|
|
162
|
+
| `Disabled` | This host has no `[email]` capability | no |
|
|
163
|
+
| `ProviderError` | The provider rejected it, or transport failed after retries | no |
|
|
163
164
|
|
|
164
165
|
`purpose` is a short, non-PII tag (`"welcome"`, `"reset"`, …). The mailer folds
|
|
165
166
|
it into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is
|
|
@@ -175,8 +176,8 @@ when the same email is sent with different values:
|
|
|
175
176
|
|
|
176
177
|
```ts
|
|
177
178
|
const welcome = new EmailTemplate(
|
|
178
|
-
'Welcome, {{name}}!',
|
|
179
|
-
'Hi {{name}}, your code is {{code}}.',
|
|
179
|
+
'Welcome, {{name}}!', // subject
|
|
180
|
+
'Hi {{name}}, your code is {{code}}.', // plain-text body
|
|
180
181
|
'<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)
|
|
181
182
|
);
|
|
182
183
|
|
|
@@ -208,12 +209,16 @@ export const subject = 'Welcome, {{name}}!';
|
|
|
208
209
|
|
|
209
210
|
export default function Welcome({ name, code }: { name: string; code: string }) {
|
|
210
211
|
return (
|
|
211
|
-
<table
|
|
212
|
+
<table
|
|
213
|
+
width="100%"
|
|
214
|
+
style={{ fontFamily: 'Arial, sans-serif' }}>
|
|
212
215
|
<tbody>
|
|
213
216
|
<tr>
|
|
214
217
|
<td style={{ padding: '24px' }}>
|
|
215
218
|
<h1 style={{ color: '#111' }}>Welcome, {name}!</h1>
|
|
216
|
-
<p>
|
|
219
|
+
<p>
|
|
220
|
+
Your code is <b>{code}</b>.
|
|
221
|
+
</p>
|
|
217
222
|
</td>
|
|
218
223
|
</tr>
|
|
219
224
|
</tbody>
|
|
@@ -232,9 +237,12 @@ const status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');
|
|
|
232
237
|
|
|
233
238
|
Authoring notes:
|
|
234
239
|
|
|
235
|
-
- **
|
|
236
|
-
inline
|
|
237
|
-
inlined for you at build (
|
|
240
|
+
- **Styles must end up inline.** Email clients strip `<style>`/external CSS, so
|
|
241
|
+
write inline `style={{ ... }}`, or import a stylesheet and its rules are
|
|
242
|
+
inlined into element `style="…"` for you at build (a bare CSS import has no
|
|
243
|
+
effect on its own under SSR). Keep email-only styles next to the email, e.g.
|
|
244
|
+
`import './styles/email.css'`, or **reuse existing project CSS** with `import
|
|
245
|
+
'client/styles/…'` (the `client/*` alias points at your client source).
|
|
238
246
|
- **Optional exports:** `export const subject` (a token template; defaults to the
|
|
239
247
|
email name), `export const text` (a plain-text alternative; otherwise derived
|
|
240
248
|
from the HTML), `export const purpose`.
|
|
@@ -246,6 +254,14 @@ Authoring notes:
|
|
|
246
254
|
- The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be
|
|
247
255
|
gitignored.
|
|
248
256
|
|
|
257
|
+
### Preview while you author
|
|
258
|
+
|
|
259
|
+
While `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the
|
|
260
|
+
link). It lists every `emails/*.tsx`, renders the selected one exactly as the
|
|
261
|
+
build does (imported `client/*` CSS inlined), lets you fill each `{{token}}` to
|
|
262
|
+
see the result, toggle the HTML and plain-text parts, and open the file in your
|
|
263
|
+
editor. It refreshes live as you edit the template or its CSS.
|
|
264
|
+
|
|
249
265
|
## Email verification codes (`TwoFactor`)
|
|
250
266
|
|
|
251
267
|
`TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.47",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"@btc-vision/as-pect-cli": "^8.3.0",
|
|
154
154
|
"@btc-vision/as-pect-transform": "^8.3.0",
|
|
155
155
|
"@clack/prompts": "^1.5.0",
|
|
156
|
-
"@microsoft/api-extractor": "7.58.
|
|
156
|
+
"@microsoft/api-extractor": "7.58.9",
|
|
157
157
|
"@testing-library/dom": "^10.4.1",
|
|
158
158
|
"@testing-library/react": "^16.3.2",
|
|
159
159
|
"@types/node": "^25.9.1",
|
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
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
/* Matches the toiljs demo brand (examples/basic/client/styles/main.css). */
|
|
126
|
+
:root {
|
|
127
|
+
color-scheme: dark;
|
|
128
|
+
--bg: #080d11; --surface: #0e1520; --surface2: #131d2e; --border: #1b2330;
|
|
129
|
+
--text: #f5f6fa; --muted: #8b9ab4; --accent: #2563ff; --accent3: #22e3ab;
|
|
130
|
+
}
|
|
131
|
+
* { box-sizing: border-box; }
|
|
132
|
+
body { margin: 0; height: 100vh; display: flex; font: 14px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
|
133
|
+
#side { width: 248px; flex: 0 0 auto; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
|
134
|
+
.brand { display: flex; align-items: center; gap: 10px; padding: 15px 16px; font-family: 'Montserrat', system-ui, sans-serif; font-weight: 800; font-size: 15px; letter-spacing: -0.01em; border-bottom: 1px solid var(--border); }
|
|
135
|
+
.brand .mark { width: 26px; height: 26px; flex: 0 0 auto; border-radius: 7px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; background: linear-gradient(135deg, var(--accent), #7c3aed 55%, var(--accent3)); }
|
|
136
|
+
#list { list-style: none; margin: 0; padding: 8px; overflow: auto; flex: 1; }
|
|
137
|
+
#list li { padding: 8px 11px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: background 150ms, color 150ms; }
|
|
138
|
+
#list li:hover { background: rgba(255,255,255,0.04); color: var(--text); }
|
|
139
|
+
#list li.on { background: rgba(37,99,255,0.14); color: #fff; box-shadow: inset 2px 0 0 var(--accent); }
|
|
140
|
+
#list li.muted, #list li.muted:hover { color: #5d6a82; cursor: default; background: none; }
|
|
141
|
+
.hint { padding: 12px 16px; font-size: 12px; color: #5d6a82; border-top: 1px solid var(--border); }
|
|
142
|
+
.hint code { color: var(--muted); }
|
|
143
|
+
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
144
|
+
.empty { margin: auto; color: #5d6a82; }
|
|
145
|
+
#view { display: flex; flex-direction: column; height: 100%; }
|
|
146
|
+
.bar { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
|
147
|
+
.subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 9px; }
|
|
148
|
+
.subj .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #5d6a82; }
|
|
149
|
+
#subject { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
150
|
+
.actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
|
151
|
+
.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
152
|
+
.seg-btn { background: var(--surface); border: 0; color: var(--muted); font: inherit; padding: 6px 14px; cursor: pointer; transition: color 150ms; }
|
|
153
|
+
.seg-btn:hover { color: var(--text); }
|
|
154
|
+
.seg-btn.on { background: var(--accent); color: #fff; }
|
|
155
|
+
.btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); font: inherit; padding: 6px 13px; border-radius: 8px; cursor: pointer; transition: border-color 150ms, background 150ms; }
|
|
156
|
+
.btn:hover { border-color: var(--accent); background: var(--surface2); }
|
|
157
|
+
.body { display: flex; flex: 1; min-height: 0; }
|
|
158
|
+
.tokens { width: 248px; flex: 0 0 auto; border-right: 1px solid var(--border); padding: 14px; overflow: auto; }
|
|
159
|
+
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 13px; }
|
|
160
|
+
.fname { font-size: 12px; color: var(--muted); }
|
|
161
|
+
.field input { background: var(--bg); border: 1px solid var(--border); border-radius: 7px; color: var(--text); font: inherit; padding: 7px 9px; }
|
|
162
|
+
.field input:focus { outline: none; border-color: var(--accent); }
|
|
163
|
+
.muted { color: #5d6a82; font-size: 12px; }
|
|
164
|
+
.preview { flex: 1; min-width: 0; display: flex; background: var(--bg); padding: 18px; }
|
|
165
|
+
#frame { flex: 1; width: 100%; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); }
|
|
166
|
+
#text { flex: 1; width: 100%; margin: 0; padding: 18px; overflow: auto; background: var(--surface); color: var(--muted); white-space: pre-wrap; border: 1px solid var(--border); border-radius: 12px; font: 13px/1.6 'SFMono-Regular', Consolas, monospace; }
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<aside id="side">
|
|
171
|
+
<div class="brand"><span class="mark">✦</span>Emails</div>
|
|
172
|
+
<ul id="list"></ul>
|
|
173
|
+
<div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
|
|
174
|
+
</aside>
|
|
175
|
+
<main id="main">
|
|
176
|
+
<div id="empty" class="empty">Select an email to preview.</div>
|
|
177
|
+
<section id="view" hidden>
|
|
178
|
+
<header class="bar">
|
|
179
|
+
<div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
|
|
180
|
+
<div class="actions">
|
|
181
|
+
<div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
|
|
182
|
+
<button id="open" class="btn">Open in editor</button>
|
|
183
|
+
</div>
|
|
184
|
+
</header>
|
|
185
|
+
<div class="body">
|
|
186
|
+
<div class="tokens" id="tokens"></div>
|
|
187
|
+
<div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
</main>
|
|
191
|
+
<script>
|
|
192
|
+
(function () {
|
|
193
|
+
var BASE = '/__toil/emails';
|
|
194
|
+
var listEl = document.getElementById('list');
|
|
195
|
+
var subjectEl = document.getElementById('subject');
|
|
196
|
+
var frame = document.getElementById('frame');
|
|
197
|
+
var textEl = document.getElementById('text');
|
|
198
|
+
var tokensEl = document.getElementById('tokens');
|
|
199
|
+
var emptyEl = document.getElementById('empty');
|
|
200
|
+
var viewEl = document.getElementById('view');
|
|
201
|
+
var tabHtml = document.getElementById('tab-html');
|
|
202
|
+
var tabText = document.getElementById('tab-text');
|
|
203
|
+
var openBtn = document.getElementById('open');
|
|
204
|
+
var current = null, rendered = null, format = 'html', values = {};
|
|
205
|
+
|
|
206
|
+
function fill(s) {
|
|
207
|
+
return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
|
|
208
|
+
return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function paint() {
|
|
212
|
+
if (!rendered) return;
|
|
213
|
+
subjectEl.textContent = fill(rendered.subject);
|
|
214
|
+
if (format === 'html') {
|
|
215
|
+
frame.hidden = false; textEl.hidden = true;
|
|
216
|
+
frame.srcdoc = fill(rendered.html);
|
|
217
|
+
} else {
|
|
218
|
+
frame.hidden = true; textEl.hidden = false;
|
|
219
|
+
textEl.textContent = fill(rendered.text);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function paintTokens() {
|
|
223
|
+
tokensEl.textContent = '';
|
|
224
|
+
if (!rendered.tokens.length) {
|
|
225
|
+
var none = document.createElement('div');
|
|
226
|
+
none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
|
|
227
|
+
tokensEl.appendChild(none); return;
|
|
228
|
+
}
|
|
229
|
+
rendered.tokens.forEach(function (t) {
|
|
230
|
+
var row = document.createElement('label'); row.className = 'field';
|
|
231
|
+
var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
|
|
232
|
+
var inp = document.createElement('input');
|
|
233
|
+
inp.value = values[t] != null ? values[t] : t;
|
|
234
|
+
values[t] = inp.value;
|
|
235
|
+
inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
|
|
236
|
+
row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function setFormat(f) {
|
|
240
|
+
format = f;
|
|
241
|
+
tabHtml.classList.toggle('on', f === 'html');
|
|
242
|
+
tabText.classList.toggle('on', f === 'text');
|
|
243
|
+
paint();
|
|
244
|
+
}
|
|
245
|
+
tabHtml.addEventListener('click', function () { setFormat('html'); });
|
|
246
|
+
tabText.addEventListener('click', function () { setFormat('text'); });
|
|
247
|
+
openBtn.addEventListener('click', function () {
|
|
248
|
+
if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
function select(item, keep) {
|
|
252
|
+
current = item;
|
|
253
|
+
Array.prototype.forEach.call(listEl.children, function (li) {
|
|
254
|
+
li.classList.toggle('on', li.getAttribute('data-name') === item.name);
|
|
255
|
+
});
|
|
256
|
+
fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
|
|
257
|
+
if (!r.ok) throw new Error('render failed');
|
|
258
|
+
return r.json();
|
|
259
|
+
}).then(function (data) {
|
|
260
|
+
rendered = data;
|
|
261
|
+
if (!keep) values = {};
|
|
262
|
+
emptyEl.hidden = true; viewEl.hidden = false;
|
|
263
|
+
paintTokens(); paint();
|
|
264
|
+
}).catch(function () {
|
|
265
|
+
emptyEl.hidden = false; viewEl.hidden = true;
|
|
266
|
+
emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function buildList(items) {
|
|
270
|
+
listEl.textContent = '';
|
|
271
|
+
if (!items.length) {
|
|
272
|
+
var li = document.createElement('li');
|
|
273
|
+
li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
|
|
274
|
+
listEl.appendChild(li); return;
|
|
275
|
+
}
|
|
276
|
+
items.forEach(function (it) {
|
|
277
|
+
var li = document.createElement('li');
|
|
278
|
+
li.setAttribute('data-name', it.name);
|
|
279
|
+
li.textContent = it.name;
|
|
280
|
+
li.classList.toggle('on', !!current && it.name === current.name);
|
|
281
|
+
li.addEventListener('click', function () { select(it, false); });
|
|
282
|
+
listEl.appendChild(li);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function refresh() {
|
|
286
|
+
fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
|
|
287
|
+
buildList(items);
|
|
288
|
+
if (!items.length) {
|
|
289
|
+
current = null; rendered = null;
|
|
290
|
+
emptyEl.hidden = false; viewEl.hidden = true;
|
|
291
|
+
emptyEl.textContent = 'No emails/*.tsx found.';
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
var match = current && items.filter(function (it) { return it.name === current.name; })[0];
|
|
295
|
+
select(match || items[0], !!match);
|
|
296
|
+
}).catch(function () {});
|
|
297
|
+
}
|
|
298
|
+
refresh();
|
|
299
|
+
// Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
|
|
300
|
+
var version = null;
|
|
301
|
+
setInterval(function () {
|
|
302
|
+
fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
|
|
303
|
+
if (version === null) { version = v; return; }
|
|
304
|
+
if (v !== version) { version = v; refresh(); }
|
|
305
|
+
}).catch(function () {});
|
|
306
|
+
}, 1000);
|
|
307
|
+
})();
|
|
308
|
+
</script>
|
|
309
|
+
</body>
|
|
310
|
+
</html>
|
|
311
|
+
`;
|
|
312
|
+
}
|
package/src/compiler/emails.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
|
-
import
|
|
24
|
+
import pc from 'picocolors';
|
|
25
|
+
import { createServer, type ViteDevServer } from 'vite';
|
|
25
26
|
|
|
26
27
|
import type { ResolvedToilConfig } from './config.js';
|
|
27
28
|
import { createViteConfig } from './vite.js';
|
|
@@ -38,7 +39,7 @@ interface EmailModule {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** One email rendered to its baked, token-holed parts. */
|
|
41
|
-
interface RenderedEmail {
|
|
42
|
+
export interface RenderedEmail {
|
|
42
43
|
name: string;
|
|
43
44
|
subject: string;
|
|
44
45
|
html: string;
|
|
@@ -132,13 +133,17 @@ async function renderModule(
|
|
|
132
133
|
name: string,
|
|
133
134
|
mod: EmailModule,
|
|
134
135
|
render: (el: unknown) => string,
|
|
136
|
+
css = '',
|
|
135
137
|
): Promise<RenderedEmail | null> {
|
|
136
138
|
if (typeof mod.default !== 'function') return null;
|
|
137
139
|
|
|
138
140
|
const seen = new Set<string>();
|
|
139
141
|
const component = mod.default as (props: unknown) => unknown;
|
|
140
142
|
let html = render(component(tokenProps(seen)));
|
|
141
|
-
|
|
143
|
+
// CSS the component imported (e.g. `import 'client/styles/email.css'`) is
|
|
144
|
+
// prepended as a <style> block so it gets inlined into element style="" like
|
|
145
|
+
// an inline block would -- under SSR a bare CSS import otherwise has no effect.
|
|
146
|
+
html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
|
|
142
147
|
|
|
143
148
|
const subject = typeof mod.subject === 'string' ? mod.subject : name;
|
|
144
149
|
const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
|
|
@@ -161,8 +166,71 @@ async function renderModule(
|
|
|
161
166
|
return { name, subject, html, text, tokens, purpose };
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Collect the CSS an email module transitively imports, as one string. Under SSR
|
|
173
|
+
* a bare `import 'client/styles/email.css'` produces no output, so we walk the
|
|
174
|
+
* Vite module graph from the email module, collect its CSS deps, and re-import
|
|
175
|
+
* each with `?inline` (Vite then returns the processed CSS as the default export,
|
|
176
|
+
* Tailwind/PostCSS included). The caller hands the result to `renderModule`,
|
|
177
|
+
* which inlines it into the HTML. Best-effort: a CSS dep that can't be inlined is
|
|
178
|
+
* skipped (the component's inline `style={{}}` props still render).
|
|
179
|
+
*/
|
|
180
|
+
export async function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string> {
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
const cssIds = new Set<string>();
|
|
183
|
+
const visit = (id: string): void => {
|
|
184
|
+
if (seen.has(id)) return;
|
|
185
|
+
seen.add(id);
|
|
186
|
+
const mod = server.moduleGraph.getModuleById(id);
|
|
187
|
+
if (!mod) return;
|
|
188
|
+
for (const dep of mod.importedModules) {
|
|
189
|
+
const depId = dep.id ?? dep.url;
|
|
190
|
+
if (!depId) continue;
|
|
191
|
+
if (CSS_RE.test(depId)) cssIds.add(depId);
|
|
192
|
+
else visit(depId);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
visit(moduleId);
|
|
196
|
+
|
|
197
|
+
let css = '';
|
|
198
|
+
for (const id of cssIds) {
|
|
199
|
+
const base = id.split('?')[0] ?? id;
|
|
200
|
+
try {
|
|
201
|
+
const mod = (await server.ssrLoadModule(`${base}?inline`)) as { default?: unknown };
|
|
202
|
+
if (typeof mod.default === 'string') css += mod.default + '\n';
|
|
203
|
+
} catch {
|
|
204
|
+
// skip a CSS dep we can't inline
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return css;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load one `emails/*.tsx` through `server` (SSR), collect any CSS it imports, and
|
|
212
|
+
* render it to its baked, token-holed parts. Shared by the build/codegen pass and
|
|
213
|
+
* the dev preview tool so both produce byte-identical output. Throws if the module
|
|
214
|
+
* fails to load; returns `null` if it has no default-exported component.
|
|
215
|
+
*/
|
|
216
|
+
export async function renderEmailFile(
|
|
217
|
+
server: ViteDevServer,
|
|
218
|
+
emailsDir: string,
|
|
219
|
+
file: string,
|
|
220
|
+
render: (el: unknown) => string,
|
|
221
|
+
): Promise<RenderedEmail | null> {
|
|
222
|
+
const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
|
|
223
|
+
const filePath = path.join(emailsDir, file);
|
|
224
|
+
const mod = (await server.ssrLoadModule(filePath)) as EmailModule;
|
|
225
|
+
const node =
|
|
226
|
+
server.moduleGraph.getModuleById(filePath) ??
|
|
227
|
+
(await server.moduleGraph.getModuleByUrl(filePath));
|
|
228
|
+
const css = node?.id ? await collectModuleCss(server, node.id) : '';
|
|
229
|
+
return renderModule(name, mod, render, css);
|
|
230
|
+
}
|
|
231
|
+
|
|
164
232
|
/** `welcome-email` / `welcome_email` -> `WelcomeEmail`. */
|
|
165
|
-
function toPascal(base: string): string {
|
|
233
|
+
export function toPascal(base: string): string {
|
|
166
234
|
return base
|
|
167
235
|
.split(/[-_\s.]+/)
|
|
168
236
|
.filter(Boolean)
|
|
@@ -222,7 +290,9 @@ function renderModuleSource(rendered: RenderedEmail[]): string {
|
|
|
222
290
|
.concat(params.map((p) => `${p.param}: string`))
|
|
223
291
|
.concat(`purpose: string = ${asLit(e.purpose)}`)
|
|
224
292
|
.join(', ');
|
|
225
|
-
out.push(
|
|
293
|
+
out.push(
|
|
294
|
+
` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`,
|
|
295
|
+
);
|
|
226
296
|
out.push(` export function send(${sig}): EmailStatus {`);
|
|
227
297
|
out.push(` const __v = new Map<string, string>();`);
|
|
228
298
|
for (const p of params) out.push(` __v.set(${asLit(p.token)}, ${p.param});`);
|
|
@@ -274,17 +344,18 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
274
344
|
const rendered: RenderedEmail[] = [];
|
|
275
345
|
try {
|
|
276
346
|
for (const file of files) {
|
|
277
|
-
const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
|
|
278
|
-
let mod: EmailModule;
|
|
279
347
|
try {
|
|
280
|
-
|
|
348
|
+
const r = await renderEmailFile(
|
|
349
|
+
server,
|
|
350
|
+
emailsDir,
|
|
351
|
+
file,
|
|
352
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
353
|
+
);
|
|
354
|
+
if (r) rendered.push(r);
|
|
355
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
281
356
|
} catch (err) {
|
|
282
357
|
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
283
|
-
continue;
|
|
284
358
|
}
|
|
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
359
|
}
|
|
289
360
|
} finally {
|
|
290
361
|
await server.close();
|
|
@@ -301,9 +372,13 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
301
372
|
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
302
373
|
fs.writeFileSync(generatedPath, next);
|
|
303
374
|
process.stdout.write(
|
|
304
|
-
|
|
305
|
-
.
|
|
306
|
-
|
|
375
|
+
pc.green(' ✓ ') +
|
|
376
|
+
pc.dim(
|
|
377
|
+
`emails: generated ${String(rendered.length)} template${rendered.length === 1 ? '' : 's'} (${rendered
|
|
378
|
+
.map((r) => r.name)
|
|
379
|
+
.join(', ')})`,
|
|
380
|
+
) +
|
|
381
|
+
'\n',
|
|
307
382
|
);
|
|
308
383
|
}
|
|
309
384
|
|