toiljs 0.0.45 → 0.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/email-preview.d.ts +12 -0
- package/build/compiler/email-preview.js +253 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +52 -12
- package/build/compiler/index.js +14 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/docs/email.md +35 -19
- package/package.json +2 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/email-preview.ts +305 -0
- package/src/compiler/emails.ts +82 -12
- package/src/compiler/index.ts +19 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/test/email-preview.test.ts +68 -0
- package/test/emails.test.ts +58 -0
|
@@ -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
|
|
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: {
|
package/build/compiler/emails.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/build/compiler/index.js
CHANGED
|
@@ -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();
|
|
@@ -228,6 +241,7 @@ export async function dev(opts = {}) {
|
|
|
228
241
|
pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
|
|
229
242
|
pc.dim(' (wasm server + vite)') +
|
|
230
243
|
'\n');
|
|
244
|
+
printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
|
|
231
245
|
watchServer(cfg, server.watcher);
|
|
232
246
|
return server;
|
|
233
247
|
}
|
package/build/compiler/plugin.js
CHANGED
|
@@ -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'
|
|
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({
|
|
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)) {
|
package/build/compiler/vite.js
CHANGED
package/docs/email.md
CHANGED
|
@@ -109,6 +109,7 @@ export default defineConfig({
|
|
|
109
109
|
},
|
|
110
110
|
});
|
|
111
111
|
```
|
|
112
|
+
|
|
112
113
|
```bash
|
|
113
114
|
# .env.secrets (gitignored)
|
|
114
115
|
TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx
|
|
@@ -137,8 +138,8 @@ class Notify {
|
|
|
137
138
|
const status = EmailService.send(
|
|
138
139
|
'alice@example.com',
|
|
139
140
|
'Welcome!',
|
|
140
|
-
'Thanks for signing up.',
|
|
141
|
-
'welcome',
|
|
141
|
+
'Thanks for signing up.', // plain-text body
|
|
142
|
+
'welcome', // purpose tag (dedup / abuse keying)
|
|
142
143
|
'<h1>Thanks for signing up.</h1>', // optional HTML body
|
|
143
144
|
);
|
|
144
145
|
return status == EmailStatus.Sent
|
|
@@ -150,16 +151,16 @@ class Notify {
|
|
|
150
151
|
|
|
151
152
|
`send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:
|
|
152
153
|
|
|
153
|
-
| Status
|
|
154
|
-
|
|
|
155
|
-
| `Sent`
|
|
156
|
-
| `Deduped`
|
|
157
|
-
| `Budget`
|
|
158
|
-
| `TryLater`
|
|
159
|
-
| `RecipientCapped` | The per-recipient hourly cap was hit
|
|
160
|
-
| `BadRecipient`
|
|
161
|
-
| `Disabled`
|
|
162
|
-
| `ProviderError`
|
|
154
|
+
| Status | Meaning | Retry? |
|
|
155
|
+
| ----------------- | ----------------------------------------------------------- | ---------------- |
|
|
156
|
+
| `Sent` | Accepted by the provider | — |
|
|
157
|
+
| `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |
|
|
158
|
+
| `Budget` | The host's per-minute budget is exhausted | yes, later |
|
|
159
|
+
| `TryLater` | The mailer was saturated / a queue was full | yes, back off |
|
|
160
|
+
| `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |
|
|
161
|
+
| `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |
|
|
162
|
+
| `Disabled` | This host has no `[email]` capability | no |
|
|
163
|
+
| `ProviderError` | The provider rejected it, or transport failed after retries | no |
|
|
163
164
|
|
|
164
165
|
`purpose` is a short, non-PII tag (`"welcome"`, `"reset"`, …). The mailer folds
|
|
165
166
|
it into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is
|
|
@@ -175,8 +176,8 @@ when the same email is sent with different values:
|
|
|
175
176
|
|
|
176
177
|
```ts
|
|
177
178
|
const welcome = new EmailTemplate(
|
|
178
|
-
'Welcome, {{name}}!',
|
|
179
|
-
'Hi {{name}}, your code is {{code}}.',
|
|
179
|
+
'Welcome, {{name}}!', // subject
|
|
180
|
+
'Hi {{name}}, your code is {{code}}.', // plain-text body
|
|
180
181
|
'<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)
|
|
181
182
|
);
|
|
182
183
|
|
|
@@ -208,12 +209,16 @@ export const subject = 'Welcome, {{name}}!';
|
|
|
208
209
|
|
|
209
210
|
export default function Welcome({ name, code }: { name: string; code: string }) {
|
|
210
211
|
return (
|
|
211
|
-
<table
|
|
212
|
+
<table
|
|
213
|
+
width="100%"
|
|
214
|
+
style={{ fontFamily: 'Arial, sans-serif' }}>
|
|
212
215
|
<tbody>
|
|
213
216
|
<tr>
|
|
214
217
|
<td style={{ padding: '24px' }}>
|
|
215
218
|
<h1 style={{ color: '#111' }}>Welcome, {name}!</h1>
|
|
216
|
-
<p>
|
|
219
|
+
<p>
|
|
220
|
+
Your code is <b>{code}</b>.
|
|
221
|
+
</p>
|
|
217
222
|
</td>
|
|
218
223
|
</tr>
|
|
219
224
|
</tbody>
|
|
@@ -232,9 +237,12 @@ const status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');
|
|
|
232
237
|
|
|
233
238
|
Authoring notes:
|
|
234
239
|
|
|
235
|
-
- **
|
|
236
|
-
inline
|
|
237
|
-
inlined for you at build (
|
|
240
|
+
- **Styles must end up inline.** Email clients strip `<style>`/external CSS, so
|
|
241
|
+
write inline `style={{ ... }}`, or import a stylesheet and its rules are
|
|
242
|
+
inlined into element `style="…"` for you at build (a bare CSS import has no
|
|
243
|
+
effect on its own under SSR). Keep email-only styles next to the email, e.g.
|
|
244
|
+
`import './styles/email.css'`, or **reuse existing project CSS** with `import
|
|
245
|
+
'client/styles/…'` (the `client/*` alias points at your client source).
|
|
238
246
|
- **Optional exports:** `export const subject` (a token template; defaults to the
|
|
239
247
|
email name), `export const text` (a plain-text alternative; otherwise derived
|
|
240
248
|
from the HTML), `export const purpose`.
|
|
@@ -246,6 +254,14 @@ Authoring notes:
|
|
|
246
254
|
- The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be
|
|
247
255
|
gitignored.
|
|
248
256
|
|
|
257
|
+
### Preview while you author
|
|
258
|
+
|
|
259
|
+
While `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the
|
|
260
|
+
link). It lists every `emails/*.tsx`, renders the selected one exactly as the
|
|
261
|
+
build does (imported `client/*` CSS inlined), lets you fill each `{{token}}` to
|
|
262
|
+
see the result, toggle the HTML and plain-text parts, and open the file in your
|
|
263
|
+
editor. It refreshes live as you edit the template or its CSS.
|
|
264
|
+
|
|
249
265
|
## Email verification codes (`TwoFactor`)
|
|
250
266
|
|
|
251
267
|
`TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.46",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"@btc-vision/as-pect-cli": "^8.3.0",
|
|
154
154
|
"@btc-vision/as-pect-transform": "^8.3.0",
|
|
155
155
|
"@clack/prompts": "^1.5.0",
|
|
156
|
-
"@microsoft/api-extractor": "7.58.
|
|
156
|
+
"@microsoft/api-extractor": "7.58.9",
|
|
157
157
|
"@testing-library/dom": "^10.4.1",
|
|
158
158
|
"@testing-library/react": "^16.3.2",
|
|
159
159
|
"@types/node": "^25.9.1",
|
package/src/cli/create.ts
CHANGED
|
@@ -156,14 +156,14 @@ function scaffold(
|
|
|
156
156
|
' "compilerOptions": {\n' +
|
|
157
157
|
' "paths": { "shared/*": ["./shared/*"] }\n' +
|
|
158
158
|
' },\n' +
|
|
159
|
-
' "include": ["client", "shared", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
|
|
159
|
+
' "include": ["client", "shared", "emails", "toil.config.ts", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
|
|
160
160
|
'}\n',
|
|
161
161
|
'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
|
|
162
162
|
'.prettierrc': '"toiljs/prettier"\n',
|
|
163
163
|
// Generated files don't need formatting. (toilscript server decorators like @main /
|
|
164
164
|
// @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
|
|
165
165
|
'.prettierignore':
|
|
166
|
-
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
166
|
+
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/toil-server-env.d.ts\n',
|
|
167
167
|
'.gitignore':
|
|
168
168
|
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
|
|
169
169
|
// Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
|