toiljs 0.0.45 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/RSG.md +21 -8
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/email-preview.d.ts +12 -0
- package/build/compiler/email-preview.js +260 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +58 -15
- package/build/compiler/index.js +37 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/docs/email.md +35 -19
- package/package.json +2 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/email-preview.ts +312 -0
- package/src/compiler/emails.ts +90 -15
- package/src/compiler/index.ts +51 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/test/email-preview.test.ts +68 -0
- package/test/emails.test.ts +58 -0
package/src/compiler/index.ts
CHANGED
|
@@ -228,6 +228,33 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
|
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Make `Ctrl+C` actually kill the dev server. Without this the process can hang
|
|
233
|
+
* on shutdown (the native uWebSockets listener / Vite's watcher don't always
|
|
234
|
+
* close promptly), so an old `toiljs dev` is left ORPHANED — still watching and
|
|
235
|
+
* rebuilding — and the next run races it (parallel double rebuilds), while the
|
|
236
|
+
* console is left with a hidden cursor. On SIGINT/SIGTERM we restore the cursor,
|
|
237
|
+
* close the servers, and force-exit after a short grace period no matter what.
|
|
238
|
+
*/
|
|
239
|
+
function installDevShutdown(close: () => Promise<void> | void): void {
|
|
240
|
+
let closing = false;
|
|
241
|
+
const shutdown = (): void => {
|
|
242
|
+
if (closing) return;
|
|
243
|
+
closing = true;
|
|
244
|
+
// Restore the cursor (anything that hid it leaves the terminal odd on exit).
|
|
245
|
+
process.stdout.write('\x1b[?25h');
|
|
246
|
+
process.stdout.write(pc.dim('\n shutting down dev server…') + '\n');
|
|
247
|
+
// Force-exit even if a server hangs on close (the orphan-prevention).
|
|
248
|
+
const hard = setTimeout(() => process.exit(0), 1500);
|
|
249
|
+
hard.unref();
|
|
250
|
+
Promise.resolve()
|
|
251
|
+
.then(close)
|
|
252
|
+
.catch(() => {})
|
|
253
|
+
.finally(() => process.exit(0));
|
|
254
|
+
};
|
|
255
|
+
for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, shutdown);
|
|
256
|
+
}
|
|
257
|
+
|
|
231
258
|
/** The server wasm artifact path from the toilconfig `release` target (toilscript's output). */
|
|
232
259
|
function serverWasmFile(root: string): string {
|
|
233
260
|
let outFile = 'build/server/release.wasm';
|
|
@@ -268,6 +295,23 @@ export interface ToilCommandOptions {
|
|
|
268
295
|
readonly serverOnly?: boolean;
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
/** Prints the email-preview URL under the dev banner, when the project has an
|
|
299
|
+
* `emails/` folder. `localUrl` is the resolved base (ends in `/`); skipped if
|
|
300
|
+
* the server didn't report one. */
|
|
301
|
+
function printEmailsUrl(cfg: ResolvedToilConfig, localUrl: string | undefined): void {
|
|
302
|
+
if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails'))) return;
|
|
303
|
+
process.stdout.write(
|
|
304
|
+
' ' +
|
|
305
|
+
pc.green('✉') +
|
|
306
|
+
' ' +
|
|
307
|
+
pc.bold('Emails') +
|
|
308
|
+
': ' +
|
|
309
|
+
pc.cyan(`${localUrl}__toil/emails`) +
|
|
310
|
+
pc.dim(' (preview)') +
|
|
311
|
+
'\n',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
271
315
|
/**
|
|
272
316
|
* Starts the dev server. Client-only projects get the plain Vite dev server on
|
|
273
317
|
* the configured port, unchanged. Projects with a server target
|
|
@@ -293,6 +337,8 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
293
337
|
const server = await createServer(await createViteConfig(cfg));
|
|
294
338
|
await server.listen();
|
|
295
339
|
server.printUrls();
|
|
340
|
+
printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
|
|
341
|
+
installDevShutdown(() => server.close());
|
|
296
342
|
return server;
|
|
297
343
|
}
|
|
298
344
|
|
|
@@ -325,10 +371,15 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
325
371
|
pc.dim(' (wasm server + vite)') +
|
|
326
372
|
'\n',
|
|
327
373
|
);
|
|
374
|
+
printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
|
|
328
375
|
|
|
329
376
|
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
330
377
|
// and the dev server hot-swaps the recompiled wasm module.
|
|
331
378
|
watchServer(cfg, server.watcher);
|
|
379
|
+
installDevShutdown(async () => {
|
|
380
|
+
await front.close();
|
|
381
|
+
await server.close();
|
|
382
|
+
});
|
|
332
383
|
return server;
|
|
333
384
|
}
|
|
334
385
|
|
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,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { createServer } from 'vite';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from '../src/compiler/config';
|
|
7
|
+
import {
|
|
8
|
+
emailsVersion,
|
|
9
|
+
listEmails,
|
|
10
|
+
previewShellHtml,
|
|
11
|
+
renderEmailByName,
|
|
12
|
+
} from '../src/compiler/email-preview';
|
|
13
|
+
import { createViteConfig } from '../src/compiler/vite';
|
|
14
|
+
|
|
15
|
+
const EXAMPLE = path.resolve(__dirname, '../examples/basic');
|
|
16
|
+
|
|
17
|
+
describe('email preview end-to-end (examples/basic)', () => {
|
|
18
|
+
it('lists Welcome and inlines its emails/styles/email.css; client/* alias resolves', async () => {
|
|
19
|
+
const cfg = await loadConfig({ root: EXAMPLE });
|
|
20
|
+
const items = listEmails(cfg);
|
|
21
|
+
expect(items.map((i) => i.name)).toContain('Welcome');
|
|
22
|
+
|
|
23
|
+
const server = await createServer({
|
|
24
|
+
...(await createViteConfig(cfg)),
|
|
25
|
+
server: { middlewareMode: true, hmr: false },
|
|
26
|
+
appType: 'custom',
|
|
27
|
+
logLevel: 'silent',
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
const r = await renderEmailByName(server, cfg, 'Welcome');
|
|
31
|
+
if (!r) throw new Error('Welcome did not render');
|
|
32
|
+
// tokens discovered from props
|
|
33
|
+
expect(r.tokens).toEqual(['code', 'name']);
|
|
34
|
+
// subject token template
|
|
35
|
+
expect(r.subject).toBe('Welcome to toiljs, {{name}}');
|
|
36
|
+
// .email-title { color: #f5f6fa } from emails/styles/email.css inlined onto the <h1>
|
|
37
|
+
expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#f5f6fa/i);
|
|
38
|
+
// .email-card { background-color: #0e1520 } inlined onto the card <table>
|
|
39
|
+
expect(r.html).toMatch(/<table[^>]*style="[^"]*background-color:\s*#0e1520/i);
|
|
40
|
+
|
|
41
|
+
// The `client/*` reuse alias still resolves project CSS (the documented
|
|
42
|
+
// `import 'client/styles/…'` path), independent of where the demo keeps its styles.
|
|
43
|
+
const aliased = (await server.ssrLoadModule('client/styles/main.css?inline')) as {
|
|
44
|
+
default?: unknown;
|
|
45
|
+
};
|
|
46
|
+
expect(typeof aliased.default).toBe('string');
|
|
47
|
+
} finally {
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
}, 30000);
|
|
51
|
+
|
|
52
|
+
it('emailsVersion is a non-empty mtime:count fingerprint', async () => {
|
|
53
|
+
const cfg = await loadConfig({ root: EXAMPLE });
|
|
54
|
+
const v = emailsVersion(cfg);
|
|
55
|
+
expect(v).toMatch(/^\d+(\.\d+)?:\d+$/);
|
|
56
|
+
// at least Welcome.tsx + the client CSS files were counted
|
|
57
|
+
expect(Number(v.split(':')[1])).toBeGreaterThan(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('the preview shell wires the dev endpoints', () => {
|
|
61
|
+
const html = previewShellHtml();
|
|
62
|
+
expect(html).toContain("var BASE = '/__toil/emails'");
|
|
63
|
+
for (const frag of ["BASE + '/list'", "BASE + '/render?name='", "BASE + '/version'"]) {
|
|
64
|
+
expect(html).toContain(frag);
|
|
65
|
+
}
|
|
66
|
+
expect(html).toContain('/__toil/open?file='); // open-in-editor
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createElement, type ReactElement } from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { __test } from '../src/compiler/emails';
|
|
6
|
+
|
|
7
|
+
const render = (el: unknown): string => renderToStaticMarkup(el as ReactElement);
|
|
8
|
+
|
|
9
|
+
describe('renderModule', () => {
|
|
10
|
+
it('discovers props as {{tokens}} and renders placeholders (alpha-sorted, deduped)', async () => {
|
|
11
|
+
const mod = {
|
|
12
|
+
default: (p: { name: string; code: string }) =>
|
|
13
|
+
createElement('p', null, `Hi ${p.name}, code ${p.code}`),
|
|
14
|
+
};
|
|
15
|
+
const r = await __test.renderModule('Welcome', mod, render);
|
|
16
|
+
if (!r) throw new Error('expected a rendered email');
|
|
17
|
+
expect(r.tokens).toEqual(['code', 'name']);
|
|
18
|
+
expect(r.html).toContain('{{name}}');
|
|
19
|
+
expect(r.html).toContain('{{code}}');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when there is no default-exported component', async () => {
|
|
23
|
+
expect(await __test.renderModule('X', { default: 'nope' }, render)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('inlines imported CSS into element style="" (the reuse path)', async () => {
|
|
27
|
+
const mod = { default: () => createElement('h1', { className: 'email-title' }, 'Hello') };
|
|
28
|
+
const css = '.email-title { color: #111827; font-size: 22px; }';
|
|
29
|
+
const r = await __test.renderModule('Styled', mod, render, css);
|
|
30
|
+
if (!r) throw new Error('expected a rendered email');
|
|
31
|
+
// The class rule is moved onto the element as an inline style by the inliner.
|
|
32
|
+
expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#111827/i);
|
|
33
|
+
expect(r.html).toMatch(/font-size:\s*22px/i);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('renderModuleSource', () => {
|
|
38
|
+
it('generates a typed Emails.<Name>.send with alpha-sorted token params', () => {
|
|
39
|
+
const src = __test.renderModuleSource([
|
|
40
|
+
{
|
|
41
|
+
name: 'Welcome',
|
|
42
|
+
subject: 'Welcome, {{name}}!',
|
|
43
|
+
html: '<p>{{code}}</p>',
|
|
44
|
+
text: 'code {{code}}',
|
|
45
|
+
tokens: ['code', 'name'],
|
|
46
|
+
purpose: 'welcome',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
expect(src).toContain('export namespace Emails {');
|
|
50
|
+
expect(src).toContain('export namespace Welcome {');
|
|
51
|
+
expect(src).toContain(
|
|
52
|
+
'export function send(to: string, code: string, name: string, purpose: string = "welcome")',
|
|
53
|
+
);
|
|
54
|
+
expect(src).toContain(
|
|
55
|
+
'return new EmailTemplate(SUBJECT, TEXT, HTML).send(to, __v, purpose);',
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|