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/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
+ }
@@ -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
- html = await inlineCss(html);
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(` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`);
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
- mod = (await server.ssrLoadModule(path.join(emailsDir, file))) as EmailModule;
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();
@@ -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.
@@ -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(`${name}/package.json`);
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(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
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' ? parsed.prompt.slice(0, 16000) : '';
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(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
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 => {
@@ -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,