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.
- package/CHANGELOG.md +9 -0
- package/RSG.md +105 -27
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- 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 +15 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/dotenv.d.ts +8 -0
- package/build/devserver/dotenv.js +59 -0
- package/build/devserver/email/caps.d.ts +9 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.d.ts +21 -0
- package/build/devserver/email/config.js +72 -0
- package/build/devserver/email/index.d.ts +25 -0
- package/build/devserver/email/index.js +57 -0
- package/build/devserver/email/providers.d.ts +12 -0
- package/build/devserver/email/providers.js +96 -0
- package/build/devserver/email/status.d.ts +10 -0
- package/build/devserver/email/status.js +11 -0
- package/build/devserver/email/validate.d.ts +2 -0
- package/build/devserver/email/validate.js +24 -0
- package/build/devserver/email/wire.d.ts +8 -0
- package/build/devserver/email/wire.js +32 -0
- package/build/devserver/env.js +5 -54
- package/build/devserver/host.js +22 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/email.md +64 -22
- package/package.json +4 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/config.ts +14 -0
- package/src/compiler/email-preview.ts +305 -0
- package/src/compiler/emails.ts +82 -12
- package/src/compiler/index.ts +20 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/src/devserver/dotenv.ts +94 -0
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +123 -0
- package/src/devserver/email/index.ts +111 -0
- package/src/devserver/email/providers.ts +130 -0
- package/src/devserver/email/status.ts +23 -0
- package/src/devserver/email/validate.ts +40 -0
- package/src/devserver/email/wire.ts +55 -0
- package/src/devserver/env.ts +8 -65
- package/src/devserver/host.ts +29 -12
- package/src/devserver/index.ts +20 -0
- package/src/shared/index.ts +36 -0
- package/test/devserver-email.test.ts +241 -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();
|
|
@@ -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
|
}
|
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