toiljs 0.0.104 → 0.0.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pc from 'picocolors';
5
+ import { createServer } from 'vite';
6
+ import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal } from './emails.js';
7
+ import { createViteConfig } from './vite.js';
8
+ function globalsDir(root) {
9
+ const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
10
+ if (fs.existsSync(inApp))
11
+ return inApp;
12
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
13
+ return path.join(pkgDir, 'server', 'globals');
14
+ }
15
+ const SPECS = {
16
+ AuthConfirm: {
17
+ fn: 'confirm',
18
+ token: 'link',
19
+ purpose: 'verify',
20
+ subjectFromArg: false,
21
+ defaultSubject: 'Confirm your account',
22
+ defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
23
+ defaultHtml: '<p>Confirm your account by clicking the link below:</p>' +
24
+ '<p><a href="{{link}}">Confirm my account</a></p>' +
25
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
26
+ },
27
+ AuthReset: {
28
+ fn: 'reset',
29
+ token: 'link',
30
+ purpose: 'reset',
31
+ subjectFromArg: false,
32
+ defaultSubject: 'Reset your password',
33
+ defaultText: 'Reset your password by opening this link:\n{{link}}\n',
34
+ defaultHtml: '<p>We received a request to reset your password. Click the link below:</p>' +
35
+ '<p><a href="{{link}}">Reset my password</a></p>' +
36
+ '<p>If you did not request this, you can ignore this email.</p>' +
37
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
38
+ },
39
+ Auth2fa: {
40
+ fn: 'twofa',
41
+ token: 'code',
42
+ purpose: '2fa',
43
+ subjectFromArg: true,
44
+ defaultSubject: '',
45
+ defaultText: 'Your verification code is {{code}}.\n' +
46
+ 'It expires in a few minutes. If you did not request it, ignore this email.\n',
47
+ defaultHtml: '<p>Your verification code is:</p>' +
48
+ '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
49
+ '<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
50
+ },
51
+ };
52
+ const AUTH_NAMES = Object.keys(SPECS);
53
+ const GENERATED_BASENAME = '_auth_emails.ts';
54
+ function warn(msg) {
55
+ process.stderr.write(` toil: auth emails ${msg}\n`);
56
+ }
57
+ function asLit(s) {
58
+ return JSON.stringify(s);
59
+ }
60
+ function reservedFilesIn(emailsDir) {
61
+ const out = new Map();
62
+ if (!fs.existsSync(emailsDir))
63
+ return out;
64
+ for (const f of fs.readdirSync(emailsDir).sort()) {
65
+ if (!/\.(tsx|jsx)$/.test(f))
66
+ continue;
67
+ const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
68
+ if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name))
69
+ out.set(name, f);
70
+ }
71
+ return out;
72
+ }
73
+ function effectiveFromOverride(name, spec, r) {
74
+ if (!r.tokens.includes(spec.token))
75
+ warn(`override emails/${name} never uses the {${spec.token}} prop, so the ` +
76
+ `${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`);
77
+ for (const t of r.tokens)
78
+ if (t !== spec.token)
79
+ warn(`override emails/${name} uses {${t}}, which auth does not provide ` +
80
+ `(only {${spec.token}}); it will render empty.`);
81
+ const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
82
+ return { subject, text: r.text, html: r.html };
83
+ }
84
+ function emitFn(spec, eff) {
85
+ const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
86
+ const sig = spec.fn === 'twofa'
87
+ ? 'twofa(to: string, code: string, subject: string): void'
88
+ : `${spec.fn}(to: string, ${spec.token}: string): void`;
89
+ return [
90
+ ` export function ${sig} {`,
91
+ ` const T: string = ${asLit(eff.text)};`,
92
+ ` const H: string = ${asLit(eff.html)};`,
93
+ ` const v = new Map<string, string>();`,
94
+ ` v.set(${asLit(spec.token)}, ${spec.token});`,
95
+ ` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
96
+ ` }`,
97
+ ];
98
+ }
99
+ function moduleSource(parts) {
100
+ const out = [
101
+ '// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
102
+ '// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
103
+ '// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
104
+ '// global (server/globals/email.ts); the detached send keeps auth constant-time.',
105
+ '',
106
+ 'export namespace AuthEmail {',
107
+ ];
108
+ for (const name of AUTH_NAMES)
109
+ out.push(...emitFn(SPECS[name], parts[name]));
110
+ out.push('}');
111
+ return out.join('\n') + '\n';
112
+ }
113
+ export async function renderAuthEmails(cfg, authOn) {
114
+ const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
115
+ if (!authOn) {
116
+ if (fs.existsSync(generatedPath))
117
+ fs.rmSync(generatedPath);
118
+ return;
119
+ }
120
+ const emailsDir = path.join(cfg.root, 'emails');
121
+ const overrides = reservedFilesIn(emailsDir);
122
+ const parts = {};
123
+ for (const name of AUTH_NAMES) {
124
+ const spec = SPECS[name];
125
+ parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
126
+ }
127
+ if (overrides.size > 0) {
128
+ const { renderToStaticMarkup } = await import('react-dom/server');
129
+ const server = await createServer({
130
+ ...(await createViteConfig(cfg)),
131
+ server: { middlewareMode: true, hmr: false },
132
+ appType: 'custom',
133
+ logLevel: 'silent',
134
+ });
135
+ try {
136
+ for (const [name, file] of overrides) {
137
+ try {
138
+ const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
139
+ if (r)
140
+ parts[name] = effectiveFromOverride(name, SPECS[name], r);
141
+ else
142
+ warn(`skipped ${file} (no default-exported component)`);
143
+ }
144
+ catch (err) {
145
+ warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
146
+ }
147
+ }
148
+ }
149
+ finally {
150
+ await server.close();
151
+ }
152
+ }
153
+ const next = moduleSource(parts);
154
+ const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
155
+ if (prev === next)
156
+ return;
157
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
158
+ fs.writeFileSync(generatedPath, next);
159
+ if (overrides.size > 0)
160
+ process.stdout.write(pc.green(' ✓ ') +
161
+ pc.dim(`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`) +
162
+ '\n');
163
+ }
164
+ export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
165
+ export const __test = { moduleSource, effectiveFromOverride, SPECS };
@@ -14,12 +14,14 @@ export interface RenderedEmail {
14
14
  tokens: string[];
15
15
  purpose: string;
16
16
  }
17
+ export declare const RESERVED_AUTH_EMAIL_NAMES: Set<string>;
17
18
  declare function tokenProps(seen: Set<string>): Record<string, unknown>;
18
19
  declare function htmlToText(html: string): string;
19
20
  declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string, css?: string): Promise<RenderedEmail | null>;
20
21
  export declare function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string>;
21
22
  export declare function renderEmailFile(server: ViteDevServer, emailsDir: string, file: string, render: (el: unknown) => string): Promise<RenderedEmail | null>;
22
23
  export declare function toPascal(base: string): string;
24
+ export declare function serverDir(root: string): string;
23
25
  declare function renderModuleSource(rendered: RenderedEmail[]): string;
24
26
  export declare function renderEmails(cfg: ResolvedToilConfig): Promise<void>;
25
27
  export declare const __test: {
@@ -15,6 +15,7 @@ const REACT_INTERNAL = new Set([
15
15
  'constructor',
16
16
  'then',
17
17
  ]);
18
+ export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
18
19
  const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
19
20
  function extractTokens(s) {
20
21
  const out = [];
@@ -133,7 +134,7 @@ export function toPascal(base) {
133
134
  .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
134
135
  .join('');
135
136
  }
136
- function serverDir(root) {
137
+ export function serverDir(root) {
137
138
  try {
138
139
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'));
139
140
  const first = Array.isArray(cfg.entries)
@@ -216,6 +217,8 @@ export async function renderEmails(cfg) {
216
217
  const rendered = [];
217
218
  try {
218
219
  for (const file of files) {
220
+ if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
221
+ continue;
219
222
  try {
220
223
  const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
221
224
  if (r)
@@ -8,6 +8,7 @@ import pc from 'picocolors';
8
8
  import { build as viteBuild, createServer, mergeConfig } from 'vite';
9
9
  import { loadConfig } from './config.js';
10
10
  import { renderEmails } from './emails.js';
11
+ import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
11
12
  import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
12
13
  import { prerenderStaticParams } from './ssg.js';
13
14
  import { extractDevSsrTemplates, extractServerSlots, extractTemplates, } from './template-build.js';
@@ -118,6 +119,13 @@ function serverImportsAuth(root, files) {
118
119
  }
119
120
  return false;
120
121
  }
122
+ function serverAuthEnabled(root, configAuth) {
123
+ if (configAuth)
124
+ return true;
125
+ if (!fs.existsSync(path.join(root, 'toilconfig.json')))
126
+ return false;
127
+ return serverImportsAuth(root, serverEntryFiles(root));
128
+ }
121
129
  export async function buildServer(root, auth = false) {
122
130
  if (!fs.existsSync(path.join(root, 'toilconfig.json')))
123
131
  return;
@@ -361,6 +369,7 @@ function watchServer(cfg, watcher) {
361
369
  building = true;
362
370
  process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
363
371
  renderEmails(cfg)
372
+ .then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
364
373
  .then(() => buildServer(root, cfg.auth))
365
374
  .then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
366
375
  .catch((e) => process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'))
@@ -376,6 +385,7 @@ function watchServer(cfg, watcher) {
376
385
  const isServerSource = (file) => file.endsWith('.ts') &&
377
386
  !file.endsWith('.d.ts') &&
378
387
  path.basename(file) !== '_emails.ts' &&
388
+ path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
379
389
  dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
380
390
  const isEmailSource = (file) => /\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
381
391
  watcher.on('error', (err) => {
@@ -552,6 +562,7 @@ export async function dev(opts = {}) {
552
562
  if (hasServer)
553
563
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
554
564
  await renderEmails(cfg);
565
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
555
566
  generate(cfg);
556
567
  if (hasServer)
557
568
  await extractServerSlots(cfg);
@@ -636,6 +647,7 @@ export async function build(opts = {}) {
636
647
  if (hasServer && !opts.serverOnly)
637
648
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
638
649
  await renderEmails(cfg);
650
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
639
651
  generate(cfg);
640
652
  const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map();
641
653
  await buildServer(cfg.root, cfg.auth);