toiljs 0.0.44 → 0.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/RSG.md +105 -27
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +12 -2
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +4 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/email-preview.d.ts +12 -0
  9. package/build/compiler/email-preview.js +253 -0
  10. package/build/compiler/emails.d.ts +6 -3
  11. package/build/compiler/emails.js +52 -12
  12. package/build/compiler/index.js +15 -0
  13. package/build/compiler/plugin.js +64 -2
  14. package/build/compiler/vite.js +1 -0
  15. package/build/devserver/.tsbuildinfo +1 -1
  16. package/build/devserver/dotenv.d.ts +8 -0
  17. package/build/devserver/dotenv.js +59 -0
  18. package/build/devserver/email/caps.d.ts +9 -0
  19. package/build/devserver/email/caps.js +0 -0
  20. package/build/devserver/email/config.d.ts +21 -0
  21. package/build/devserver/email/config.js +72 -0
  22. package/build/devserver/email/index.d.ts +25 -0
  23. package/build/devserver/email/index.js +57 -0
  24. package/build/devserver/email/providers.d.ts +12 -0
  25. package/build/devserver/email/providers.js +96 -0
  26. package/build/devserver/email/status.d.ts +10 -0
  27. package/build/devserver/email/status.js +11 -0
  28. package/build/devserver/email/validate.d.ts +2 -0
  29. package/build/devserver/email/validate.js +24 -0
  30. package/build/devserver/email/wire.d.ts +8 -0
  31. package/build/devserver/email/wire.js +32 -0
  32. package/build/devserver/env.js +5 -54
  33. package/build/devserver/host.js +22 -7
  34. package/build/devserver/index.d.ts +2 -0
  35. package/build/devserver/index.js +8 -0
  36. package/build/shared/.tsbuildinfo +1 -1
  37. package/build/shared/index.d.ts +13 -0
  38. package/docs/email.md +64 -22
  39. package/package.json +4 -2
  40. package/src/cli/create.ts +2 -2
  41. package/src/cli/doctor.ts +15 -0
  42. package/src/compiler/config.ts +14 -0
  43. package/src/compiler/email-preview.ts +305 -0
  44. package/src/compiler/emails.ts +82 -12
  45. package/src/compiler/index.ts +20 -0
  46. package/src/compiler/plugin.ts +88 -4
  47. package/src/compiler/vite.ts +4 -0
  48. package/src/devserver/dotenv.ts +94 -0
  49. package/src/devserver/email/caps.ts +0 -0
  50. package/src/devserver/email/config.ts +123 -0
  51. package/src/devserver/email/index.ts +111 -0
  52. package/src/devserver/email/providers.ts +130 -0
  53. package/src/devserver/email/status.ts +23 -0
  54. package/src/devserver/email/validate.ts +40 -0
  55. package/src/devserver/email/wire.ts +55 -0
  56. package/src/devserver/env.ts +8 -65
  57. package/src/devserver/host.ts +29 -12
  58. package/src/devserver/index.ts +20 -0
  59. package/src/shared/index.ts +36 -0
  60. package/test/devserver-email.test.ts +241 -0
  61. package/test/email-preview.test.ts +68 -0
  62. package/test/emails.test.ts +58 -0
@@ -0,0 +1,253 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { renderEmailFile, toPascal } from './emails.js';
4
+ export function emailsDir(cfg) {
5
+ return path.join(cfg.root, 'emails');
6
+ }
7
+ export function emailsVersion(cfg) {
8
+ let newest = 0;
9
+ let count = 0;
10
+ const CSS = /\.(css|scss|sass|less|styl|pcss|postcss)$/;
11
+ const walk = (dir, match) => {
12
+ let entries;
13
+ try {
14
+ entries = fs.readdirSync(dir, { withFileTypes: true });
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const e of entries) {
20
+ const full = path.join(dir, e.name);
21
+ if (e.isDirectory()) {
22
+ if (e.name !== 'node_modules')
23
+ walk(full, match);
24
+ }
25
+ else if (match.test(e.name)) {
26
+ try {
27
+ const m = fs.statSync(full).mtimeMs;
28
+ if (m > newest)
29
+ newest = m;
30
+ count++;
31
+ }
32
+ catch {
33
+ }
34
+ }
35
+ }
36
+ };
37
+ walk(emailsDir(cfg), /\.(tsx|jsx|css|scss|sass|less|styl|pcss|postcss)$/);
38
+ walk(cfg.clientAbsDir, CSS);
39
+ return `${String(newest)}:${String(count)}`;
40
+ }
41
+ export function listEmails(cfg) {
42
+ const dir = emailsDir(cfg);
43
+ if (!fs.existsSync(dir))
44
+ return [];
45
+ return fs
46
+ .readdirSync(dir)
47
+ .filter((f) => /\.(tsx|jsx)$/.test(f))
48
+ .sort()
49
+ .map((f) => ({
50
+ name: toPascal(path.basename(f).replace(/\.(tsx|jsx)$/, '')),
51
+ file: path.join(dir, f),
52
+ }));
53
+ }
54
+ export async function renderEmailByName(server, cfg, name) {
55
+ const item = listEmails(cfg).find((e) => e.name === name);
56
+ if (!item)
57
+ return null;
58
+ const node = server.moduleGraph.getModuleById(item.file) ??
59
+ (await server.moduleGraph.getModuleByUrl(item.file));
60
+ if (node)
61
+ server.moduleGraph.invalidateModule(node);
62
+ const { renderToStaticMarkup } = await import('react-dom/server');
63
+ return renderEmailFile(server, emailsDir(cfg), path.basename(item.file), renderToStaticMarkup);
64
+ }
65
+ export function previewShellHtml() {
66
+ return `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
71
+ <title>Email preview · toiljs</title>
72
+ <style>
73
+ :root { color-scheme: dark; }
74
+ * { box-sizing: border-box; }
75
+ body { margin: 0; height: 100vh; display: flex; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #0c0c11; color: #e7e9f0; }
76
+ #side { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; display: flex; flex-direction: column; background: #101016; }
77
+ .brand { padding: 14px 16px; font-weight: 600; border-bottom: 1px solid #23232e; }
78
+ #list { list-style: none; margin: 0; padding: 6px; overflow: auto; flex: 1; }
79
+ #list li { padding: 8px 10px; border-radius: 8px; cursor: pointer; color: #c8cee0; }
80
+ #list li:hover { background: #181820; }
81
+ #list li.on { background: #1d1d6b33; color: #fff; }
82
+ #list li.muted { color: #6b7080; cursor: default; }
83
+ .hint { padding: 10px 16px; font-size: 12px; color: #6b7080; border-top: 1px solid #23232e; }
84
+ .hint code { color: #9aa1b8; }
85
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
86
+ .empty { margin: auto; color: #6b7080; }
87
+ #view { display: flex; flex-direction: column; height: 100%; }
88
+ .bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #23232e; }
89
+ .subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 8px; }
90
+ .subj .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #6b7080; }
91
+ #subject { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
92
+ .actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
93
+ .seg { display: flex; border: 1px solid #2c2c38; border-radius: 8px; overflow: hidden; }
94
+ .seg-btn { background: #15151c; border: 0; color: #8b90a4; font: inherit; padding: 6px 12px; cursor: pointer; }
95
+ .seg-btn.on { background: #2563ff; color: #fff; }
96
+ .btn { background: #15151c; border: 1px solid #2c2c38; color: #c8cee0; font: inherit; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
97
+ .btn:hover { color: #fff; border-color: #3a3a48; }
98
+ .body { display: flex; flex: 1; min-height: 0; }
99
+ .tokens { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; padding: 12px; overflow: auto; }
100
+ .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
101
+ .fname { font-size: 12px; color: #9aa1b8; }
102
+ .field input { background: #0c0c11; border: 1px solid #2c2c38; border-radius: 6px; color: #e7e9f0; font: inherit; padding: 6px 8px; }
103
+ .field input:focus { outline: none; border-color: #2563ff; }
104
+ .muted { color: #6b7080; font-size: 12px; }
105
+ .preview { flex: 1; min-width: 0; background: #f6f7f9; }
106
+ #frame { width: 100%; height: 100%; border: 0; background: #fff; }
107
+ #text { width: 100%; height: 100%; margin: 0; padding: 16px; overflow: auto; background: #0c0c11; color: #c8cee0; white-space: pre-wrap; }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <aside id="side">
112
+ <div class="brand">✉ Emails</div>
113
+ <ul id="list"></ul>
114
+ <div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
115
+ </aside>
116
+ <main id="main">
117
+ <div id="empty" class="empty">Select an email to preview.</div>
118
+ <section id="view" hidden>
119
+ <header class="bar">
120
+ <div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
121
+ <div class="actions">
122
+ <div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
123
+ <button id="open" class="btn">Open in editor</button>
124
+ </div>
125
+ </header>
126
+ <div class="body">
127
+ <div class="tokens" id="tokens"></div>
128
+ <div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
129
+ </div>
130
+ </section>
131
+ </main>
132
+ <script>
133
+ (function () {
134
+ var BASE = '/__toil/emails';
135
+ var listEl = document.getElementById('list');
136
+ var subjectEl = document.getElementById('subject');
137
+ var frame = document.getElementById('frame');
138
+ var textEl = document.getElementById('text');
139
+ var tokensEl = document.getElementById('tokens');
140
+ var emptyEl = document.getElementById('empty');
141
+ var viewEl = document.getElementById('view');
142
+ var tabHtml = document.getElementById('tab-html');
143
+ var tabText = document.getElementById('tab-text');
144
+ var openBtn = document.getElementById('open');
145
+ var current = null, rendered = null, format = 'html', values = {};
146
+
147
+ function fill(s) {
148
+ return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
149
+ return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
150
+ });
151
+ }
152
+ function paint() {
153
+ if (!rendered) return;
154
+ subjectEl.textContent = fill(rendered.subject);
155
+ if (format === 'html') {
156
+ frame.hidden = false; textEl.hidden = true;
157
+ frame.srcdoc = fill(rendered.html);
158
+ } else {
159
+ frame.hidden = true; textEl.hidden = false;
160
+ textEl.textContent = fill(rendered.text);
161
+ }
162
+ }
163
+ function paintTokens() {
164
+ tokensEl.textContent = '';
165
+ if (!rendered.tokens.length) {
166
+ var none = document.createElement('div');
167
+ none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
168
+ tokensEl.appendChild(none); return;
169
+ }
170
+ rendered.tokens.forEach(function (t) {
171
+ var row = document.createElement('label'); row.className = 'field';
172
+ var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
173
+ var inp = document.createElement('input');
174
+ inp.value = values[t] != null ? values[t] : t;
175
+ values[t] = inp.value;
176
+ inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
177
+ row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
178
+ });
179
+ }
180
+ function setFormat(f) {
181
+ format = f;
182
+ tabHtml.classList.toggle('on', f === 'html');
183
+ tabText.classList.toggle('on', f === 'text');
184
+ paint();
185
+ }
186
+ tabHtml.addEventListener('click', function () { setFormat('html'); });
187
+ tabText.addEventListener('click', function () { setFormat('text'); });
188
+ openBtn.addEventListener('click', function () {
189
+ if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
190
+ });
191
+
192
+ function select(item, keep) {
193
+ current = item;
194
+ Array.prototype.forEach.call(listEl.children, function (li) {
195
+ li.classList.toggle('on', li.getAttribute('data-name') === item.name);
196
+ });
197
+ fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
198
+ if (!r.ok) throw new Error('render failed');
199
+ return r.json();
200
+ }).then(function (data) {
201
+ rendered = data;
202
+ if (!keep) values = {};
203
+ emptyEl.hidden = true; viewEl.hidden = false;
204
+ paintTokens(); paint();
205
+ }).catch(function () {
206
+ emptyEl.hidden = false; viewEl.hidden = true;
207
+ emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
208
+ });
209
+ }
210
+ function buildList(items) {
211
+ listEl.textContent = '';
212
+ if (!items.length) {
213
+ var li = document.createElement('li');
214
+ li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
215
+ listEl.appendChild(li); return;
216
+ }
217
+ items.forEach(function (it) {
218
+ var li = document.createElement('li');
219
+ li.setAttribute('data-name', it.name);
220
+ li.textContent = it.name;
221
+ li.classList.toggle('on', !!current && it.name === current.name);
222
+ li.addEventListener('click', function () { select(it, false); });
223
+ listEl.appendChild(li);
224
+ });
225
+ }
226
+ function refresh() {
227
+ fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
228
+ buildList(items);
229
+ if (!items.length) {
230
+ current = null; rendered = null;
231
+ emptyEl.hidden = false; viewEl.hidden = true;
232
+ emptyEl.textContent = 'No emails/*.tsx found.';
233
+ return;
234
+ }
235
+ var match = current && items.filter(function (it) { return it.name === current.name; })[0];
236
+ select(match || items[0], !!match);
237
+ }).catch(function () {});
238
+ }
239
+ refresh();
240
+ // Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
241
+ var version = null;
242
+ setInterval(function () {
243
+ fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
244
+ if (version === null) { version = v; return; }
245
+ if (v !== version) { version = v; refresh(); }
246
+ }).catch(function () {});
247
+ }, 1000);
248
+ })();
249
+ </script>
250
+ </body>
251
+ </html>
252
+ `;
253
+ }
@@ -1,3 +1,4 @@
1
+ import { type ViteDevServer } from 'vite';
1
2
  import type { ResolvedToilConfig } from './config.js';
2
3
  interface EmailModule {
3
4
  default: unknown;
@@ -5,7 +6,7 @@ interface EmailModule {
5
6
  text?: unknown;
6
7
  purpose?: unknown;
7
8
  }
8
- interface RenderedEmail {
9
+ export interface RenderedEmail {
9
10
  name: string;
10
11
  subject: string;
11
12
  html: string;
@@ -15,8 +16,10 @@ interface RenderedEmail {
15
16
  }
16
17
  declare function tokenProps(seen: Set<string>): Record<string, unknown>;
17
18
  declare function htmlToText(html: string): string;
18
- declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string): Promise<RenderedEmail | null>;
19
- declare function toPascal(base: string): string;
19
+ declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string, css?: string): Promise<RenderedEmail | null>;
20
+ export declare function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string>;
21
+ export declare function renderEmailFile(server: ViteDevServer, emailsDir: string, file: string, render: (el: unknown) => string): Promise<RenderedEmail | null>;
22
+ export declare function toPascal(base: string): string;
20
23
  declare function renderModuleSource(rendered: RenderedEmail[]): string;
21
24
  export declare function renderEmails(cfg: ResolvedToilConfig): Promise<void>;
22
25
  export declare const __test: {
@@ -59,13 +59,13 @@ function htmlToText(html) {
59
59
  .replace(/[ \t]*\n[ \t]*/g, '\n')
60
60
  .trim();
61
61
  }
62
- async function renderModule(name, mod, render) {
62
+ async function renderModule(name, mod, render, css = '') {
63
63
  if (typeof mod.default !== 'function')
64
64
  return null;
65
65
  const seen = new Set();
66
66
  const component = mod.default;
67
67
  let html = render(component(tokenProps(seen)));
68
- html = await inlineCss(html);
68
+ html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
69
69
  const subject = typeof mod.subject === 'string' ? mod.subject : name;
70
70
  const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
71
71
  const purpose = typeof mod.purpose === 'string' && mod.purpose.length > 0
@@ -81,7 +81,51 @@ async function renderModule(name, mod, render) {
81
81
  ].sort();
82
82
  return { name, subject, html, text, tokens, purpose };
83
83
  }
84
- function toPascal(base) {
84
+ const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
85
+ export async function collectModuleCss(server, moduleId) {
86
+ const seen = new Set();
87
+ const cssIds = new Set();
88
+ const visit = (id) => {
89
+ if (seen.has(id))
90
+ return;
91
+ seen.add(id);
92
+ const mod = server.moduleGraph.getModuleById(id);
93
+ if (!mod)
94
+ return;
95
+ for (const dep of mod.importedModules) {
96
+ const depId = dep.id ?? dep.url;
97
+ if (!depId)
98
+ continue;
99
+ if (CSS_RE.test(depId))
100
+ cssIds.add(depId);
101
+ else
102
+ visit(depId);
103
+ }
104
+ };
105
+ visit(moduleId);
106
+ let css = '';
107
+ for (const id of cssIds) {
108
+ const base = id.split('?')[0] ?? id;
109
+ try {
110
+ const mod = (await server.ssrLoadModule(`${base}?inline`));
111
+ if (typeof mod.default === 'string')
112
+ css += mod.default + '\n';
113
+ }
114
+ catch {
115
+ }
116
+ }
117
+ return css;
118
+ }
119
+ export async function renderEmailFile(server, emailsDir, file, render) {
120
+ const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
121
+ const filePath = path.join(emailsDir, file);
122
+ const mod = (await server.ssrLoadModule(filePath));
123
+ const node = server.moduleGraph.getModuleById(filePath) ??
124
+ (await server.moduleGraph.getModuleByUrl(filePath));
125
+ const css = node?.id ? await collectModuleCss(server, node.id) : '';
126
+ return renderModule(name, mod, render, css);
127
+ }
128
+ export function toPascal(base) {
85
129
  return base
86
130
  .split(/[-_\s.]+/)
87
131
  .filter(Boolean)
@@ -171,20 +215,16 @@ export async function renderEmails(cfg) {
171
215
  const rendered = [];
172
216
  try {
173
217
  for (const file of files) {
174
- const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
175
- let mod;
176
218
  try {
177
- mod = (await server.ssrLoadModule(path.join(emailsDir, file)));
219
+ const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
220
+ if (r)
221
+ rendered.push(r);
222
+ else
223
+ warn(`skipped ${file} (no default-exported component)`);
178
224
  }
179
225
  catch (err) {
180
226
  warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
181
- continue;
182
227
  }
183
- const r = await renderModule(name, mod, renderToStaticMarkup);
184
- if (r)
185
- rendered.push(r);
186
- else
187
- warn(`skipped ${file} (no default-exported component)`);
188
228
  }
189
229
  }
190
230
  finally {
@@ -187,6 +187,18 @@ async function freeLoopbackPort() {
187
187
  });
188
188
  });
189
189
  }
190
+ function printEmailsUrl(cfg, localUrl) {
191
+ if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails')))
192
+ return;
193
+ process.stdout.write(' ' +
194
+ pc.green('✉') +
195
+ ' ' +
196
+ pc.bold('Emails') +
197
+ ': ' +
198
+ pc.cyan(`${localUrl}__toil/emails`) +
199
+ pc.dim(' (preview)') +
200
+ '\n');
201
+ }
190
202
  export async function dev(opts = {}) {
191
203
  const cfg = await loadConfig(opts);
192
204
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
@@ -201,6 +213,7 @@ export async function dev(opts = {}) {
201
213
  const server = await createServer(await createViteConfig(cfg));
202
214
  await server.listen();
203
215
  server.printUrls();
216
+ printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
204
217
  return server;
205
218
  }
206
219
  const vitePort = await freeLoopbackPort();
@@ -215,6 +228,7 @@ export async function dev(opts = {}) {
215
228
  port: cfg.port,
216
229
  wasmFile: serverWasmFile(cfg.root),
217
230
  vite: { host: '127.0.0.1', port: vitePort },
231
+ email: cfg.email ?? undefined,
218
232
  });
219
233
  server.httpServer?.once('close', () => {
220
234
  void front.close();
@@ -227,6 +241,7 @@ export async function dev(opts = {}) {
227
241
  pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
228
242
  pc.dim(' (wasm server + vite)') +
229
243
  '\n');
244
+ printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
230
245
  watchServer(cfg, server.watcher);
231
246
  return server;
232
247
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { version as viteVersion } from 'vite';
7
7
  import { AiProvider } from './config.js';
8
+ import { emailsVersion, listEmails, previewShellHtml, renderEmailByName } from './email-preview.js';
8
9
  import { generate } from './generate.js';
9
10
  import { scanRoutes } from './routes.js';
10
11
  async function aiComplete(ai, prompt) {
@@ -191,7 +192,9 @@ export function toilPlugin(cfg) {
191
192
  void (async () => {
192
193
  try {
193
194
  const parsed = JSON.parse(body || '{}');
194
- const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
195
+ const prompt = typeof parsed.prompt === 'string'
196
+ ? parsed.prompt.slice(0, 16000)
197
+ : '';
195
198
  const text = await aiComplete(ai, prompt);
196
199
  res.setHeader('content-type', 'application/json');
197
200
  res.end(JSON.stringify({ text }));
@@ -200,7 +203,9 @@ export function toilPlugin(cfg) {
200
203
  process.stderr.write(`toil: /__toil/ai failed: ${e instanceof Error ? e.message : String(e)}\n`);
201
204
  res.statusCode = 500;
202
205
  res.setHeader('content-type', 'application/json');
203
- res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
206
+ res.end(JSON.stringify({
207
+ error: 'AI request failed (see dev server logs).',
208
+ }));
204
209
  }
205
210
  })();
206
211
  });
@@ -247,6 +252,63 @@ export function toilPlugin(cfg) {
247
252
  res.end();
248
253
  }
249
254
  });
255
+ server.middlewares.use('/__toil/emails/list', (req, res) => {
256
+ if (isCrossSiteRequest(req.headers)) {
257
+ res.statusCode = 403;
258
+ res.end();
259
+ return;
260
+ }
261
+ res.setHeader('content-type', 'application/json');
262
+ res.end(JSON.stringify(listEmails(cfg)));
263
+ });
264
+ server.middlewares.use('/__toil/emails/render', (req, res) => {
265
+ if (isCrossSiteRequest(req.headers)) {
266
+ res.statusCode = 403;
267
+ res.end();
268
+ return;
269
+ }
270
+ void (async () => {
271
+ try {
272
+ const name = new URL(req.url ?? '', 'http://localhost').searchParams.get('name');
273
+ if (!name) {
274
+ res.statusCode = 400;
275
+ res.end();
276
+ return;
277
+ }
278
+ const rendered = await renderEmailByName(server, cfg, name);
279
+ if (!rendered) {
280
+ res.statusCode = 404;
281
+ res.end();
282
+ return;
283
+ }
284
+ res.setHeader('content-type', 'application/json');
285
+ res.end(JSON.stringify(rendered));
286
+ }
287
+ catch (e) {
288
+ process.stderr.write(`toil: /__toil/emails/render failed: ${e instanceof Error ? e.message : String(e)}\n`);
289
+ res.statusCode = 500;
290
+ res.end();
291
+ }
292
+ })();
293
+ });
294
+ server.middlewares.use('/__toil/emails/version', (req, res) => {
295
+ if (isCrossSiteRequest(req.headers)) {
296
+ res.statusCode = 403;
297
+ res.end();
298
+ return;
299
+ }
300
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
301
+ res.end(emailsVersion(cfg));
302
+ });
303
+ server.middlewares.use('/__toil/emails', (req, res) => {
304
+ if (isCrossSiteRequest(req.headers)) {
305
+ res.statusCode = 403;
306
+ res.end();
307
+ return;
308
+ }
309
+ res.setHeader('content-type', 'text/html; charset=utf-8');
310
+ res.end(previewShellHtml());
311
+ });
250
312
  const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
251
313
  const onChange = (file) => {
252
314
  if (file.replace(/\\/g, '/').startsWith(routesPrefix)) {
@@ -98,6 +98,7 @@ export async function createViteConfig(cfg) {
98
98
  alias: {
99
99
  'toiljs/client': cfg.runtimePath,
100
100
  'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
101
+ client: cfg.clientAbsDir,
101
102
  ...polyfillShimAliases,
102
103
  },
103
104
  dedupe: ['react', 'react-dom'],