toiljs 0.0.45 → 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/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 +253 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +52 -12
- package/build/compiler/index.js +14 -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 +305 -0
- package/src/compiler/emails.ts +82 -12
- package/src/compiler/index.ts +19 -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/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,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
|
|
|
@@ -325,6 +343,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
325
343
|
pc.dim(' (wasm server + vite)') +
|
|
326
344
|
'\n',
|
|
327
345
|
);
|
|
346
|
+
printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
|
|
328
347
|
|
|
329
348
|
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
330
349
|
// and the dev server hot-swaps the recompiled wasm module.
|
package/src/compiler/plugin.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { type Plugin, version as viteVersion } from 'vite';
|
|
9
9
|
|
|
10
10
|
import { AiProvider, type DevtoolsAiConfig, type ResolvedToilConfig } from './config.js';
|
|
11
|
+
import { emailsVersion, listEmails, previewShellHtml, renderEmailByName } from './email-preview.js';
|
|
11
12
|
import { generate } from './generate.js';
|
|
12
13
|
import { scanRoutes } from './routes.js';
|
|
13
14
|
|
|
@@ -58,7 +59,9 @@ async function aiComplete(ai: DevtoolsAiConfig, prompt: string): Promise<string>
|
|
|
58
59
|
/** Reads a package's version resolved from `<fromDir>`, or 'unknown'. */
|
|
59
60
|
function depVersion(fromDir: string, name: string): string {
|
|
60
61
|
try {
|
|
61
|
-
const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(
|
|
62
|
+
const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(
|
|
63
|
+
`${name}/package.json`,
|
|
64
|
+
);
|
|
62
65
|
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
|
|
63
66
|
return raw.version ?? 'unknown';
|
|
64
67
|
} catch {
|
|
@@ -69,7 +72,12 @@ function depVersion(fromDir: string, name: string): string {
|
|
|
69
72
|
/** toiljs's own version (package.json two levels up from build/compiler). */
|
|
70
73
|
function frameworkVersion(): string {
|
|
71
74
|
try {
|
|
72
|
-
const p = path.resolve(
|
|
75
|
+
const p = path.resolve(
|
|
76
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
77
|
+
'..',
|
|
78
|
+
'..',
|
|
79
|
+
'package.json',
|
|
80
|
+
);
|
|
73
81
|
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as { version?: string };
|
|
74
82
|
return raw.version ?? '0.0.0';
|
|
75
83
|
} catch {
|
|
@@ -231,7 +239,9 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
231
239
|
const parsed = JSON.parse(body || '{}') as { prompt?: string };
|
|
232
240
|
// Cap the prompt actually forwarded upstream (independent of the raw-body cap).
|
|
233
241
|
const prompt =
|
|
234
|
-
typeof parsed.prompt === 'string'
|
|
242
|
+
typeof parsed.prompt === 'string'
|
|
243
|
+
? parsed.prompt.slice(0, 16000)
|
|
244
|
+
: '';
|
|
235
245
|
const text = await aiComplete(ai, prompt);
|
|
236
246
|
res.setHeader('content-type', 'application/json');
|
|
237
247
|
res.end(JSON.stringify({ text }));
|
|
@@ -243,7 +253,11 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
243
253
|
);
|
|
244
254
|
res.statusCode = 500;
|
|
245
255
|
res.setHeader('content-type', 'application/json');
|
|
246
|
-
res.end(
|
|
256
|
+
res.end(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
error: 'AI request failed (see dev server logs).',
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
247
261
|
}
|
|
248
262
|
})();
|
|
249
263
|
});
|
|
@@ -291,6 +305,76 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
291
305
|
}
|
|
292
306
|
});
|
|
293
307
|
|
|
308
|
+
// Email preview tool (dev only). `/__toil/emails` -> a standalone page that lists
|
|
309
|
+
// `emails/*.tsx`, renders the selected one through the live SSR server (so edits and
|
|
310
|
+
// imported `client/*` CSS show), and live-refreshes by polling `/version`.
|
|
311
|
+
// Sub-paths are registered before the page so connect's prefix match resolves them first.
|
|
312
|
+
server.middlewares.use('/__toil/emails/list', (req, res) => {
|
|
313
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
314
|
+
res.statusCode = 403;
|
|
315
|
+
res.end();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
res.setHeader('content-type', 'application/json');
|
|
319
|
+
res.end(JSON.stringify(listEmails(cfg)));
|
|
320
|
+
});
|
|
321
|
+
server.middlewares.use('/__toil/emails/render', (req, res) => {
|
|
322
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
323
|
+
res.statusCode = 403;
|
|
324
|
+
res.end();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
void (async () => {
|
|
328
|
+
try {
|
|
329
|
+
const name = new URL(req.url ?? '', 'http://localhost').searchParams.get(
|
|
330
|
+
'name',
|
|
331
|
+
);
|
|
332
|
+
if (!name) {
|
|
333
|
+
res.statusCode = 400;
|
|
334
|
+
res.end();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const rendered = await renderEmailByName(server, cfg, name);
|
|
338
|
+
if (!rendered) {
|
|
339
|
+
res.statusCode = 404;
|
|
340
|
+
res.end();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
res.setHeader('content-type', 'application/json');
|
|
344
|
+
res.end(JSON.stringify(rendered));
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// Log the detail to the dev's terminal; the page shows a generic message.
|
|
347
|
+
process.stderr.write(
|
|
348
|
+
`toil: /__toil/emails/render failed: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
349
|
+
);
|
|
350
|
+
res.statusCode = 500;
|
|
351
|
+
res.end();
|
|
352
|
+
}
|
|
353
|
+
})();
|
|
354
|
+
});
|
|
355
|
+
// A tiny mtime fingerprint of `emails/*` + client CSS the page polls (~1s) to detect
|
|
356
|
+
// edits. Polling (not SSE) because the wasm dev server proxies `/__toil/*` to Vite by
|
|
357
|
+
// buffering the whole response, so a long-lived stream would hang; a short poll works
|
|
358
|
+
// in every mode.
|
|
359
|
+
server.middlewares.use('/__toil/emails/version', (req, res) => {
|
|
360
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
361
|
+
res.statusCode = 403;
|
|
362
|
+
res.end();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
366
|
+
res.end(emailsVersion(cfg));
|
|
367
|
+
});
|
|
368
|
+
server.middlewares.use('/__toil/emails', (req, res) => {
|
|
369
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
370
|
+
res.statusCode = 403;
|
|
371
|
+
res.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
375
|
+
res.end(previewShellHtml());
|
|
376
|
+
});
|
|
377
|
+
|
|
294
378
|
// Trailing slash so a sibling like `routes-extra/` doesn't match the `routes/` prefix.
|
|
295
379
|
const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
|
|
296
380
|
const onChange = (file: string): void => {
|
package/src/compiler/vite.ts
CHANGED
|
@@ -154,6 +154,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
|
|
|
154
154
|
alias: {
|
|
155
155
|
'toiljs/client': cfg.runtimePath,
|
|
156
156
|
'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
|
|
157
|
+
// `client/*` -> the project's client source dir, so anything (pages, and notably
|
|
158
|
+
// `emails/*.tsx`) can reuse existing client assets, e.g. `import 'client/styles/x.css'`.
|
|
159
|
+
// Vite's string alias matches only `client` or `client/...`, never `toiljs/client`.
|
|
160
|
+
client: cfg.clientAbsDir,
|
|
157
161
|
// `shared/*` is resolved by sharedResolverPlugin (above) so a missing generated
|
|
158
162
|
// shared/server.ts gives an actionable error instead of an opaque load failure.
|
|
159
163
|
...polyfillShimAliases,
|