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.
@@ -0,0 +1,260 @@
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
+ /* Matches the toiljs demo brand (examples/basic/client/styles/main.css). */
74
+ :root {
75
+ color-scheme: dark;
76
+ --bg: #080d11; --surface: #0e1520; --surface2: #131d2e; --border: #1b2330;
77
+ --text: #f5f6fa; --muted: #8b9ab4; --accent: #2563ff; --accent3: #22e3ab;
78
+ }
79
+ * { box-sizing: border-box; }
80
+ 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); }
81
+ #side { width: 248px; flex: 0 0 auto; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
82
+ .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); }
83
+ .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)); }
84
+ #list { list-style: none; margin: 0; padding: 8px; overflow: auto; flex: 1; }
85
+ #list li { padding: 8px 11px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: background 150ms, color 150ms; }
86
+ #list li:hover { background: rgba(255,255,255,0.04); color: var(--text); }
87
+ #list li.on { background: rgba(37,99,255,0.14); color: #fff; box-shadow: inset 2px 0 0 var(--accent); }
88
+ #list li.muted, #list li.muted:hover { color: #5d6a82; cursor: default; background: none; }
89
+ .hint { padding: 12px 16px; font-size: 12px; color: #5d6a82; border-top: 1px solid var(--border); }
90
+ .hint code { color: var(--muted); }
91
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
92
+ .empty { margin: auto; color: #5d6a82; }
93
+ #view { display: flex; flex-direction: column; height: 100%; }
94
+ .bar { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
95
+ .subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 9px; }
96
+ .subj .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #5d6a82; }
97
+ #subject { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
98
+ .actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
99
+ .seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
100
+ .seg-btn { background: var(--surface); border: 0; color: var(--muted); font: inherit; padding: 6px 14px; cursor: pointer; transition: color 150ms; }
101
+ .seg-btn:hover { color: var(--text); }
102
+ .seg-btn.on { background: var(--accent); color: #fff; }
103
+ .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; }
104
+ .btn:hover { border-color: var(--accent); background: var(--surface2); }
105
+ .body { display: flex; flex: 1; min-height: 0; }
106
+ .tokens { width: 248px; flex: 0 0 auto; border-right: 1px solid var(--border); padding: 14px; overflow: auto; }
107
+ .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 13px; }
108
+ .fname { font-size: 12px; color: var(--muted); }
109
+ .field input { background: var(--bg); border: 1px solid var(--border); border-radius: 7px; color: var(--text); font: inherit; padding: 7px 9px; }
110
+ .field input:focus { outline: none; border-color: var(--accent); }
111
+ .muted { color: #5d6a82; font-size: 12px; }
112
+ .preview { flex: 1; min-width: 0; display: flex; background: var(--bg); padding: 18px; }
113
+ #frame { flex: 1; width: 100%; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); }
114
+ #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; }
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <aside id="side">
119
+ <div class="brand"><span class="mark">✦</span>Emails</div>
120
+ <ul id="list"></ul>
121
+ <div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
122
+ </aside>
123
+ <main id="main">
124
+ <div id="empty" class="empty">Select an email to preview.</div>
125
+ <section id="view" hidden>
126
+ <header class="bar">
127
+ <div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
128
+ <div class="actions">
129
+ <div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
130
+ <button id="open" class="btn">Open in editor</button>
131
+ </div>
132
+ </header>
133
+ <div class="body">
134
+ <div class="tokens" id="tokens"></div>
135
+ <div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
136
+ </div>
137
+ </section>
138
+ </main>
139
+ <script>
140
+ (function () {
141
+ var BASE = '/__toil/emails';
142
+ var listEl = document.getElementById('list');
143
+ var subjectEl = document.getElementById('subject');
144
+ var frame = document.getElementById('frame');
145
+ var textEl = document.getElementById('text');
146
+ var tokensEl = document.getElementById('tokens');
147
+ var emptyEl = document.getElementById('empty');
148
+ var viewEl = document.getElementById('view');
149
+ var tabHtml = document.getElementById('tab-html');
150
+ var tabText = document.getElementById('tab-text');
151
+ var openBtn = document.getElementById('open');
152
+ var current = null, rendered = null, format = 'html', values = {};
153
+
154
+ function fill(s) {
155
+ return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
156
+ return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
157
+ });
158
+ }
159
+ function paint() {
160
+ if (!rendered) return;
161
+ subjectEl.textContent = fill(rendered.subject);
162
+ if (format === 'html') {
163
+ frame.hidden = false; textEl.hidden = true;
164
+ frame.srcdoc = fill(rendered.html);
165
+ } else {
166
+ frame.hidden = true; textEl.hidden = false;
167
+ textEl.textContent = fill(rendered.text);
168
+ }
169
+ }
170
+ function paintTokens() {
171
+ tokensEl.textContent = '';
172
+ if (!rendered.tokens.length) {
173
+ var none = document.createElement('div');
174
+ none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
175
+ tokensEl.appendChild(none); return;
176
+ }
177
+ rendered.tokens.forEach(function (t) {
178
+ var row = document.createElement('label'); row.className = 'field';
179
+ var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
180
+ var inp = document.createElement('input');
181
+ inp.value = values[t] != null ? values[t] : t;
182
+ values[t] = inp.value;
183
+ inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
184
+ row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
185
+ });
186
+ }
187
+ function setFormat(f) {
188
+ format = f;
189
+ tabHtml.classList.toggle('on', f === 'html');
190
+ tabText.classList.toggle('on', f === 'text');
191
+ paint();
192
+ }
193
+ tabHtml.addEventListener('click', function () { setFormat('html'); });
194
+ tabText.addEventListener('click', function () { setFormat('text'); });
195
+ openBtn.addEventListener('click', function () {
196
+ if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
197
+ });
198
+
199
+ function select(item, keep) {
200
+ current = item;
201
+ Array.prototype.forEach.call(listEl.children, function (li) {
202
+ li.classList.toggle('on', li.getAttribute('data-name') === item.name);
203
+ });
204
+ fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
205
+ if (!r.ok) throw new Error('render failed');
206
+ return r.json();
207
+ }).then(function (data) {
208
+ rendered = data;
209
+ if (!keep) values = {};
210
+ emptyEl.hidden = true; viewEl.hidden = false;
211
+ paintTokens(); paint();
212
+ }).catch(function () {
213
+ emptyEl.hidden = false; viewEl.hidden = true;
214
+ emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
215
+ });
216
+ }
217
+ function buildList(items) {
218
+ listEl.textContent = '';
219
+ if (!items.length) {
220
+ var li = document.createElement('li');
221
+ li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
222
+ listEl.appendChild(li); return;
223
+ }
224
+ items.forEach(function (it) {
225
+ var li = document.createElement('li');
226
+ li.setAttribute('data-name', it.name);
227
+ li.textContent = it.name;
228
+ li.classList.toggle('on', !!current && it.name === current.name);
229
+ li.addEventListener('click', function () { select(it, false); });
230
+ listEl.appendChild(li);
231
+ });
232
+ }
233
+ function refresh() {
234
+ fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
235
+ buildList(items);
236
+ if (!items.length) {
237
+ current = null; rendered = null;
238
+ emptyEl.hidden = false; viewEl.hidden = true;
239
+ emptyEl.textContent = 'No emails/*.tsx found.';
240
+ return;
241
+ }
242
+ var match = current && items.filter(function (it) { return it.name === current.name; })[0];
243
+ select(match || items[0], !!match);
244
+ }).catch(function () {});
245
+ }
246
+ refresh();
247
+ // Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
248
+ var version = null;
249
+ setInterval(function () {
250
+ fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
251
+ if (version === null) { version = v; return; }
252
+ if (v !== version) { version = v; refresh(); }
253
+ }).catch(function () {});
254
+ }, 1000);
255
+ })();
256
+ </script>
257
+ </body>
258
+ </html>
259
+ `;
260
+ }
@@ -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: {
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import pc from 'picocolors';
3
4
  import { createServer } from 'vite';
4
5
  import { createViteConfig } from './vite.js';
5
6
  const REACT_INTERNAL = new Set([
@@ -59,13 +60,13 @@ function htmlToText(html) {
59
60
  .replace(/[ \t]*\n[ \t]*/g, '\n')
60
61
  .trim();
61
62
  }
62
- async function renderModule(name, mod, render) {
63
+ async function renderModule(name, mod, render, css = '') {
63
64
  if (typeof mod.default !== 'function')
64
65
  return null;
65
66
  const seen = new Set();
66
67
  const component = mod.default;
67
68
  let html = render(component(tokenProps(seen)));
68
- html = await inlineCss(html);
69
+ html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
69
70
  const subject = typeof mod.subject === 'string' ? mod.subject : name;
70
71
  const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
71
72
  const purpose = typeof mod.purpose === 'string' && mod.purpose.length > 0
@@ -81,7 +82,51 @@ async function renderModule(name, mod, render) {
81
82
  ].sort();
82
83
  return { name, subject, html, text, tokens, purpose };
83
84
  }
84
- function toPascal(base) {
85
+ const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
86
+ export async function collectModuleCss(server, moduleId) {
87
+ const seen = new Set();
88
+ const cssIds = new Set();
89
+ const visit = (id) => {
90
+ if (seen.has(id))
91
+ return;
92
+ seen.add(id);
93
+ const mod = server.moduleGraph.getModuleById(id);
94
+ if (!mod)
95
+ return;
96
+ for (const dep of mod.importedModules) {
97
+ const depId = dep.id ?? dep.url;
98
+ if (!depId)
99
+ continue;
100
+ if (CSS_RE.test(depId))
101
+ cssIds.add(depId);
102
+ else
103
+ visit(depId);
104
+ }
105
+ };
106
+ visit(moduleId);
107
+ let css = '';
108
+ for (const id of cssIds) {
109
+ const base = id.split('?')[0] ?? id;
110
+ try {
111
+ const mod = (await server.ssrLoadModule(`${base}?inline`));
112
+ if (typeof mod.default === 'string')
113
+ css += mod.default + '\n';
114
+ }
115
+ catch {
116
+ }
117
+ }
118
+ return css;
119
+ }
120
+ export async function renderEmailFile(server, emailsDir, file, render) {
121
+ const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
122
+ const filePath = path.join(emailsDir, file);
123
+ const mod = (await server.ssrLoadModule(filePath));
124
+ const node = server.moduleGraph.getModuleById(filePath) ??
125
+ (await server.moduleGraph.getModuleByUrl(filePath));
126
+ const css = node?.id ? await collectModuleCss(server, node.id) : '';
127
+ return renderModule(name, mod, render, css);
128
+ }
129
+ export function toPascal(base) {
85
130
  return base
86
131
  .split(/[-_\s.]+/)
87
132
  .filter(Boolean)
@@ -171,20 +216,16 @@ export async function renderEmails(cfg) {
171
216
  const rendered = [];
172
217
  try {
173
218
  for (const file of files) {
174
- const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
175
- let mod;
176
219
  try {
177
- mod = (await server.ssrLoadModule(path.join(emailsDir, file)));
220
+ const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
221
+ if (r)
222
+ rendered.push(r);
223
+ else
224
+ warn(`skipped ${file} (no default-exported component)`);
178
225
  }
179
226
  catch (err) {
180
227
  warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
181
- continue;
182
228
  }
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
229
  }
189
230
  }
190
231
  finally {
@@ -198,8 +239,10 @@ export async function renderEmails(cfg) {
198
239
  return;
199
240
  fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
200
241
  fs.writeFileSync(generatedPath, next);
201
- process.stdout.write(`emails: generated ${String(rendered.length)} template${rendered.length === 1 ? '' : 's'} (${rendered
202
- .map((r) => r.name)
203
- .join(', ')})\n`);
242
+ process.stdout.write(pc.green('') +
243
+ pc.dim(`emails: generated ${String(rendered.length)} template${rendered.length === 1 ? '' : 's'} (${rendered
244
+ .map((r) => r.name)
245
+ .join(', ')})`) +
246
+ '\n');
204
247
  }
205
248
  export const __test = { renderModule, renderModuleSource, tokenProps, htmlToText, toPascal };
@@ -162,6 +162,24 @@ function watchServer(cfg, watcher) {
162
162
  timer = setTimeout(rebuild, 150);
163
163
  });
164
164
  }
165
+ function installDevShutdown(close) {
166
+ let closing = false;
167
+ const shutdown = () => {
168
+ if (closing)
169
+ return;
170
+ closing = true;
171
+ process.stdout.write('\x1b[?25h');
172
+ process.stdout.write(pc.dim('\n shutting down dev server…') + '\n');
173
+ const hard = setTimeout(() => process.exit(0), 1500);
174
+ hard.unref();
175
+ Promise.resolve()
176
+ .then(close)
177
+ .catch(() => { })
178
+ .finally(() => process.exit(0));
179
+ };
180
+ for (const sig of ['SIGINT', 'SIGTERM'])
181
+ process.once(sig, shutdown);
182
+ }
165
183
  function serverWasmFile(root) {
166
184
  let outFile = 'build/server/release.wasm';
167
185
  try {
@@ -187,6 +205,18 @@ async function freeLoopbackPort() {
187
205
  });
188
206
  });
189
207
  }
208
+ function printEmailsUrl(cfg, localUrl) {
209
+ if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails')))
210
+ return;
211
+ process.stdout.write(' ' +
212
+ pc.green('✉') +
213
+ ' ' +
214
+ pc.bold('Emails') +
215
+ ': ' +
216
+ pc.cyan(`${localUrl}__toil/emails`) +
217
+ pc.dim(' (preview)') +
218
+ '\n');
219
+ }
190
220
  export async function dev(opts = {}) {
191
221
  const cfg = await loadConfig(opts);
192
222
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
@@ -201,6 +231,8 @@ export async function dev(opts = {}) {
201
231
  const server = await createServer(await createViteConfig(cfg));
202
232
  await server.listen();
203
233
  server.printUrls();
234
+ printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
235
+ installDevShutdown(() => server.close());
204
236
  return server;
205
237
  }
206
238
  const vitePort = await freeLoopbackPort();
@@ -228,7 +260,12 @@ export async function dev(opts = {}) {
228
260
  pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
229
261
  pc.dim(' (wasm server + vite)') +
230
262
  '\n');
263
+ printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
231
264
  watchServer(cfg, server.watcher);
265
+ installDevShutdown(async () => {
266
+ await front.close();
267
+ await server.close();
268
+ });
232
269
  return server;
233
270
  }
234
271
  export async function build(opts = {}) {
@@ -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'],