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
|
@@ -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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
202
|
-
.
|
|
203
|
-
|
|
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 };
|
package/build/compiler/index.js
CHANGED
|
@@ -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 = {}) {
|
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