@travetto/email-compiler 3.1.20 → 3.1.22

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/__index__.ts CHANGED
@@ -1,2 +1,2 @@
1
- export * from './src/resource';
2
- export * from './src/compiler';
1
+ export * from './src/compiler';
2
+ export * from './src/util';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/email-compiler",
3
- "version": "3.1.20",
3
+ "version": "3.1.22",
4
4
  "description": "Email compiling module",
5
5
  "keywords": [
6
6
  "email",
@@ -26,11 +26,11 @@
26
26
  "directory": "module/email-compiler"
27
27
  },
28
28
  "dependencies": {
29
- "@travetto/base": "^3.1.2",
30
- "@travetto/config": "^3.1.6",
31
- "@travetto/di": "^3.1.3",
32
- "@travetto/email": "^3.1.12",
33
- "@travetto/image": "^3.1.3",
29
+ "@travetto/base": "^3.1.3",
30
+ "@travetto/config": "^3.1.7",
31
+ "@travetto/di": "^3.1.4",
32
+ "@travetto/email": "^3.1.13",
33
+ "@travetto/image": "^3.1.4",
34
34
  "@types/inline-css": "^3.0.1",
35
35
  "html-entities": "^2.3.3",
36
36
  "inline-css": "^4.0.2",
@@ -38,7 +38,7 @@
38
38
  "purgecss": "^5.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@travetto/cli": "^3.1.7"
41
+ "@travetto/cli": "^3.1.8"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@travetto/cli": {
package/src/compiler.ts CHANGED
@@ -1,187 +1,145 @@
1
1
  import fs from 'fs/promises';
2
- import util from 'util';
3
- import { decode as decodeEntities } from 'html-entities';
4
2
 
5
- import { ImageConverter } from '@travetto/image';
6
- import { RootIndex, path } from '@travetto/manifest';
7
- import { StreamUtil } from '@travetto/base';
3
+ import { FileQueryProvider, TypedObject } from '@travetto/base';
8
4
  import { MessageCompilationSource, MessageCompiled } from '@travetto/email';
5
+ import { RootIndex, path } from '@travetto/manifest';
6
+ import { DynamicFileLoader } from '@travetto/base/src/internal/file-loader';
7
+
8
+ import { EmailCompileUtil } from './util';
9
9
 
10
- import { EmailCompilerResource } from './resource';
10
+ const VALID_FILE = (file: string): boolean => /[.](scss|css|png|jpe?g|gif|ya?ml)$/.test(file) && !/[.]compiled[.]/.test(file);
11
11
 
12
12
  /**
13
- * Utilities for templating
13
+ * Email compilation support
14
14
  */
15
15
  export class EmailCompiler {
16
16
 
17
- static HTML_CSS_IMAGE_URLS = [
18
- /(?<pre><img[^>]src=\s*["'])(?<src>[^"]+)/g,
19
- /(?<pre>background(?:-image)?:\s*url[(]['"]?)(?<src>[^"')]+)/g
20
- ];
17
+ /** Load Template */
18
+ static async loadTemplate(file: string): Promise<MessageCompilationSource> {
19
+ const entry = RootIndex.getEntry(file);
20
+ if (!entry) {
21
+ throw new Error(`Unable to find template for ${file}`);
22
+ }
23
+ const root = (await import(entry.outputFile)).default;
24
+ const res: MessageCompilationSource = { ...await root.wrap(), file: entry.sourceFile };
25
+ const mod = RootIndex.getModule(entry.module)!;
21
26
 
22
- static async readText(text: Promise<string> | string): Promise<string> {
23
- return decodeEntities((await text).replace(/&#xA0;/g, ' '));
24
- }
27
+ const resourcePaths = [
28
+ path.resolve(mod.sourcePath, 'resources'),
29
+ path.resolve(RootIndex.manifest.workspacePath, 'resources')
30
+ ];
25
31
 
26
- /**
27
- * Compile SCSS content with roots as search paths for additional assets
28
- */
29
- static async compileSass(src: { data: string } | { file: string }, roots: string[]): Promise<string> {
30
- const sass = await import('sass');
31
- const result = await util.promisify(sass.render)({
32
- ...src,
33
- sourceMap: false,
34
- includePaths: roots
35
- });
36
- return result!.css.toString();
37
- }
32
+ const styles = res.styles ??= {};
33
+ (styles.search ??= []).push(...resourcePaths);
38
34
 
39
- resources: EmailCompilerResource;
35
+ const images = res.images ??= {};
36
+ (images.search ??= []).push(...resourcePaths);
40
37
 
41
- constructor(resources: EmailCompilerResource) {
42
- this.resources = resources;
38
+ return res;
43
39
  }
44
40
 
45
41
  /**
46
- * Inline image sources
42
+ * Grab list of all available templates
47
43
  */
48
- async inlineImageSource(html: string): Promise<string> {
49
- const { tokens, finalize } = await this.resources.tokenizeResources(html, EmailCompiler.HTML_CSS_IMAGE_URLS);
50
- const pendingImages: Promise<[token: string, content: string]>[] = [];
51
-
52
- for (const [token, src] of tokens) {
53
- const ext = path.extname(src).replace(/^[.]/, '');
54
- const stream = await this.resources.readStream(src);
55
-
56
- switch (ext) {
57
- case 'jpg':
58
- case 'jpeg':
59
- case 'png': {
60
- pendingImages.push(
61
- ImageConverter.optimize(ext === 'png' ? 'png' : 'jpeg', stream)
62
- .then(img => StreamUtil.streamToBuffer(img))
63
- .then(data => [token, `data:image/${ext};base64,${data.toString('base64')}`])
64
- );
65
- break;
66
- }
67
- default: {
68
- pendingImages.push(
69
- StreamUtil.streamToBuffer(stream)
70
- .then(data => [token, `data:image/${ext};base64,${data.toString('base64')}`])
71
- );
72
- }
73
- }
74
- }
75
-
76
- const imageMap = new Map(await Promise.all(pendingImages));
77
-
78
- return finalize(token => imageMap.get(token)!);
44
+ static findAllTemplates(mod?: string): string[] {
45
+ return RootIndex
46
+ .findSupport({ filter: f => EmailCompileUtil.isTemplateFile(f) })
47
+ .filter(x => !mod || x.module === mod)
48
+ .map(x => x.sourceFile);
79
49
  }
80
50
 
81
- async pruneCss(html: string, css: string): Promise<string> {
82
- const { PurgeCSS } = await import('purgecss');
83
- const purge = new PurgeCSS();
84
- const result = await purge.purge({
85
- content: [{ raw: html, extension: 'html' }],
86
- css: [{ raw: css }],
87
- });
88
- return result[0].css;
51
+ /**
52
+ * Get output files
53
+ */
54
+ static getOutputFiles(file: string): MessageCompiled {
55
+ const entry = RootIndex.getEntry(file)!;
56
+ const mod = RootIndex.getModule(entry.module)!;
57
+ return EmailCompileUtil.getOutputs(file, path.join(mod.sourcePath, 'resources'));
89
58
  }
90
59
 
91
- async inlineCss(html: string, css: string): Promise<string> {
92
- // Inline css
93
- const { default: inlineCss } = await import('inline-css');
94
- html = html.replace('</head>', `<style>${css}</style></head>`);
95
- // Style needs to be in head to preserve media queries
96
- html = (await inlineCss(html, {
97
- url: 'https://app.dev',
98
- preserveMediaQueries: true,
99
- removeStyleTags: true,
100
- removeLinkTags: true,
101
- applyStyleTags: true,
102
- }));
103
- return html;
60
+ /**
61
+ * Get the sending email key from a template file
62
+ */
63
+ static async templateFileToKey(file: string): Promise<string> {
64
+ return EmailCompileUtil.buildOutputPath(file, '');
104
65
  }
105
66
 
106
67
  /**
107
- * Compile all
68
+ * Write template to file
108
69
  */
109
- async compileAll(persist = false): Promise<MessageCompiled[]> {
110
- const keys = await this.resources.findAllTemplates();
111
- return Promise.all(keys.map(src => this.compile(src, persist)));
70
+ static async writeTemplate(file: string, msg: MessageCompiled): Promise<void> {
71
+ const outs = this.getOutputFiles(file);
72
+ await Promise.all(TypedObject.keys(outs).map(async k => {
73
+ if (msg[k]) {
74
+ await fs.mkdir(path.dirname(outs[k]), { recursive: true });
75
+ await fs.writeFile(outs[k], msg[k], { encoding: 'utf8' });
76
+ } else {
77
+ await fs.unlink(outs[k]).catch(() => { }); // Remove file if data not provided
78
+ }
79
+ }));
112
80
  }
113
81
 
114
82
  /**
115
- * Compile template
83
+ * Compile a file given a resource provider
116
84
  */
117
- async compile(src: string | MessageCompilationSource, persist = false): Promise<MessageCompiled> {
118
- if (typeof src === 'string') {
119
- src = await this.resources.loadTemplate(src);
85
+ static async compile(file: string, persist: boolean = false): Promise<MessageCompiled> {
86
+ const src = await this.loadTemplate(file);
87
+ const compiled = await EmailCompileUtil.compile(src);
88
+ if (persist) {
89
+ await this.writeTemplate(file, compiled);
120
90
  }
91
+ return compiled;
92
+ }
121
93
 
122
- const subject = await EmailCompiler.readText(src.subject());
123
- const text = await EmailCompiler.readText(src.text());
124
-
125
- let html = (await src.html())
126
- .replace(/<(meta|img|link|hr|br)[^>]*>/g, a => a.replace('>', '/>')) // Fix self closing
127
- .replace(/&apos;/g, '&#39;'); // Fix apostrophes, as outlook hates them
128
-
129
- if (src.inlineStyles !== false) {
130
- const styles: string[] = [];
94
+ /**
95
+ * Compile all
96
+ */
97
+ static async compileAll(mod?: string): Promise<string[]> {
98
+ const keys = this.findAllTemplates(mod);
99
+ await Promise.all(keys.map(src => this.compile(src, true)));
100
+ return keys;
101
+ }
131
102
 
132
- if (src.styles?.global) {
133
- styles.push(src.styles.global);
134
- }
135
103
 
136
- const main = await this.resources.read('/email/main.scss').then(d => d, () => '');
137
- if (main) {
138
- styles.push(main);
104
+ /**
105
+ * Watch compilation
106
+ */
107
+ static async * watchCompile(): AsyncIterable<string> {
108
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
109
+ const all = new FileQueryProvider(
110
+ [...new Set(this.findAllTemplates()
111
+ .map(x => RootIndex.getEntry(x)!.module)
112
+ )].map(x => path.resolve(RootIndex.getModule(x)!.sourcePath, 'resources'))
113
+ );
114
+
115
+ const stream = all.watchFiles();
116
+
117
+ DynamicFileLoader.onLoadEvent((ev) => {
118
+ const src = RootIndex.getEntry(ev.file);
119
+ if (src && EmailCompileUtil.isTemplateFile(src.sourceFile)) {
120
+ stream.add({ ...ev, file: src.sourceFile });
139
121
  }
140
-
141
- if (styles.length) {
142
- const compiled = await EmailCompiler.compileSass(
143
- { data: styles.join('\n') },
144
- [...src.styles?.search ?? [], ...this.resources.getAllPaths()]);
145
-
146
- // Remove all unused styles
147
- const finalStyles = await this.pruneCss(html, compiled);
148
-
149
- // Apply styles
150
- html = await this.inlineCss(html, finalStyles);
122
+ });
123
+ for await (const { file, action } of stream) {
124
+ if (action === 'delete') {
125
+ continue;
151
126
  }
152
- }
153
127
 
154
- // Fix up style behaviors
155
- html = html
156
- .replace(/(background(?:-color)?:\s*)([#0-9a-f]{6,8})([^>.#,]+)>/ig,
157
- (all, p, col, rest) => `${p}${col}${rest} bgcolor="${col}">`) // Inline bg-color
158
- .replace(/<([^>]+vertical-align:\s*(top|bottom|middle)[^>]+)>/g,
159
- (a, tag, valign) => tag.indexOf('valign') ? `<${tag}>` : `<${tag} valign="${valign}">`) // Vertically align if it has the style
160
- .replace(/<(table[^>]+expand[^>]+width:\s*)(100%\s+!important)([^>]+)>/g,
161
- (a, left, size, right) => `<${left}100%${right}>`); // Drop important as a fix for outlook
162
-
163
- if (src.inlineImages !== false) {
164
- // Inline Images
165
- html = await this.inlineImageSource(html);
166
- }
167
-
168
- // Write to disk, if desired
169
- if (persist) {
170
- const outs = this.resources.getOutputs(src.file!, path.join(RootIndex.mainModule.sourcePath, 'resources'));
171
- await Promise.all([
172
- [outs.text, text],
173
- [outs.html, html],
174
- [outs.subject, subject]
175
- ].map(async ([file, content]) => {
176
- if (content) {
177
- await fs.mkdir(path.dirname(file), { recursive: true });
178
- await fs.writeFile(file, content, { encoding: 'utf8' });
179
- } else {
180
- await fs.unlink(file).catch(() => { }); // Remove file if data not provided
128
+ try {
129
+ if (EmailCompileUtil.isTemplateFile(file)) {
130
+ await this.compile(file, true);
131
+ console.log(`Successfully compiled ${1} templates`, { changed: [file] });
132
+ yield file;
133
+ } else if (VALID_FILE(file)) {
134
+ const rootFile = file.replace(/\/resources.*/, '/package.json');
135
+ const mod = RootIndex.getFromSource(rootFile)!.module;
136
+ const changed = await this.compileAll(mod);
137
+ console.log(`Successfully compiled ${changed.length} templates`, { changed, file });
138
+ yield* changed;
181
139
  }
182
- }));
140
+ } catch (err) {
141
+ console.error(`Error in compiling ${file}`, err && err instanceof Error ? err.message : `${err}`);
142
+ }
183
143
  }
184
-
185
- return { html, text, subject };
186
144
  }
187
145
  }
package/src/util.ts ADDED
@@ -0,0 +1,221 @@
1
+ import util from 'util';
2
+ import { Readable } from 'stream';
3
+
4
+ import { FileResourceProvider, StreamUtil } from '@travetto/base';
5
+ import { MessageCompilationImages, MessageCompilationSource, MessageCompilationStyles, MessageCompiled } from '@travetto/email';
6
+ import { ImageConverter } from '@travetto/image';
7
+ import { path } from '@travetto/manifest';
8
+
9
+
10
+ type Tokenized = {
11
+ text: string;
12
+ tokens: Map<string, string>;
13
+ finalize: (onToken: (token: string) => string) => string;
14
+ };
15
+
16
+ /**
17
+ * Email compile tools
18
+ */
19
+ export class EmailCompileUtil {
20
+ static #HTML_CSS_IMAGE_URLS = [
21
+ /(?<pre><img[^>]src=\s*["'])(?<src>[^"]+)/g,
22
+ /(?<pre>background(?:-image)?:\s*url[(]['"]?)(?<src>[^"')]+)/g
23
+ ];
24
+
25
+ static #EXT = /[.]email[.]tsx$/;
26
+
27
+ /**
28
+ * Is file a template?
29
+ */
30
+ static isTemplateFile(file: string): boolean {
31
+ return this.#EXT.test(file);
32
+ }
33
+
34
+ /**
35
+ * Generate singular output path given a file
36
+ */
37
+ static buildOutputPath(file: string, suffix: string, prefix?: string): string {
38
+ const res = file.replace(/.*(support|src)\//, '').replace(this.#EXT, suffix);
39
+ return prefix ? path.join(prefix, res) : res;
40
+ }
41
+
42
+ /**
43
+ * Get the different parts from the file name
44
+ */
45
+ static getOutputs(file: string, prefix?: string): MessageCompiled {
46
+ return {
47
+ html: this.buildOutputPath(file, '.compiled.html', prefix),
48
+ subject: this.buildOutputPath(file, '.compiled.subject', prefix),
49
+ text: this.buildOutputPath(file, '.compiled.text', prefix),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Run through text and match/resolve resource urls, producing tokens
55
+ *
56
+ * @param text
57
+ * @param patterns
58
+ * @returns
59
+ */
60
+ static async tokenizeResources(text: string, patterns: RegExp[]): Promise<Tokenized> {
61
+ let id = 0;
62
+ const tokens = new Map();
63
+ for (const pattern of patterns) {
64
+ for (const { [0]: all, groups: { pre, src } = { pre: '', src: '' } } of text.matchAll(pattern)) {
65
+ if (src.includes('://')) { // No urls
66
+ continue;
67
+ }
68
+ const token = `@@${id += 1}@@`;
69
+ tokens.set(token, src);
70
+ text = text.replace(all, `${pre}${token}`);
71
+ }
72
+ }
73
+ const finalize = (onToken: (token: string) => string): string => text.replace(/@@[^@]+@@/g, t => onToken(t));
74
+
75
+ return { text, tokens, finalize };
76
+ }
77
+
78
+ /**
79
+ * Compile SCSS content with roots as search paths for additional assets
80
+ */
81
+ static async compileSass(src: { data: string } | { file: string }, roots: string[]): Promise<string> {
82
+ const sass = await import('sass');
83
+ const result = await util.promisify(sass.render)({
84
+ ...src,
85
+ sourceMap: false,
86
+ includePaths: roots
87
+ });
88
+ return result!.css.toString();
89
+ }
90
+
91
+ /**
92
+ * Prunes unused css given html document
93
+ */
94
+ static async pruneCss(html: string, css: string): Promise<string> {
95
+ const { PurgeCSS } = await import('purgecss');
96
+ const purge = new PurgeCSS();
97
+ const [result] = await purge.purge({
98
+ content: [{ raw: html, extension: 'html' }],
99
+ css: [{ raw: css }],
100
+ });
101
+ return result.css;
102
+ }
103
+
104
+ /**
105
+ * Moves CSS inline into html output, minus media queries
106
+ */
107
+ static async inlineCss(html: string, css: string): Promise<string> {
108
+ // Inline css
109
+ const { default: inlineCss } = await import('inline-css');
110
+ return inlineCss(
111
+ // Style needs to be in head to preserve media queries
112
+ html.replace('</head>', `<style>${css}</style></head>`),
113
+ {
114
+ url: 'https://app.dev',
115
+ preserveMediaQueries: true,
116
+ removeStyleTags: true,
117
+ removeLinkTags: true,
118
+ applyStyleTags: true,
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Simplifies text by decoding all entities
124
+ *
125
+ * @param text
126
+ */
127
+ static async simplifiedText(text: string): Promise<string> {
128
+ const { decode } = await import('html-entities');
129
+ return decode(text.replace(/&#xA0;/g, ' '));
130
+ }
131
+
132
+ /**
133
+ * Inline image sources
134
+ */
135
+ static async inlineImages(html: string, opts: MessageCompilationImages): Promise<string> {
136
+ const { tokens, finalize } = await this.tokenizeResources(html, this.#HTML_CSS_IMAGE_URLS);
137
+ const pendingImages: [token: string, ext: string, stream: Readable | Promise<Readable>][] = [];
138
+ const resources = new FileResourceProvider(opts.search ?? []);
139
+
140
+ for (const [token, src] of tokens) {
141
+ const ext = path.extname(src);
142
+ const stream = await resources.readStream(src);
143
+ pendingImages.push([token, ext, /^[.](jpe?g|png)$/.test(ext) ?
144
+ ImageConverter.optimize(ext === '.png' ? 'png' : 'jpeg', stream) : stream]);
145
+ }
146
+
147
+ const imageMap = new Map(await Promise.all(pendingImages.map(async ([token, ext, stream]) => {
148
+ const data = await StreamUtil.streamToBuffer(await stream);
149
+ return [token, `data:image/${ext};base64,${data.toString('base64')}`] as const;
150
+ })));
151
+
152
+ return finalize(token => imageMap.get(token)!);
153
+ }
154
+
155
+ /**
156
+ * Handle various edge cases
157
+ */
158
+ static handleHtmlEdgeCases(html: string): string {
159
+ return html
160
+ .replace(/<(meta|img|link|hr|br)[^>]*>/g, a => a.replace('>', '/>')) // Fix self closing
161
+ .replace(/&apos;/g, '&#39;') // Fix apostrophes, as outlook hates them
162
+ .replace(/(background(?:-color)?:\s*)([#0-9a-f]{6,8})([^>.#,]+)>/ig,
163
+ (all, p, col, rest) => `${p}${col}${rest} bgcolor="${col}">`) // Inline bg-color
164
+ .replace(/<([^>]+vertical-align:\s*(top|bottom|middle)[^>]+)>/g,
165
+ (a, tag, valign) => tag.indexOf('valign') ? `<${tag}>` : `<${tag} valign="${valign}">`) // Vertically align if it has the style
166
+ .replace(/<(table[^>]+expand[^>]+width:\s*)(100%\s+!important)([^>]+)>/g,
167
+ (a, left, size, right) => `<${left}100%${right}>`); // Drop important as a fix for outlook
168
+ }
169
+
170
+
171
+ /**
172
+ * Apply styles into a given html document
173
+ */
174
+ static async applyStyles(html: string, opts: MessageCompilationStyles): Promise<string> {
175
+ const styles: string[] = [];
176
+
177
+ if (opts.global) {
178
+ styles.push(opts.global);
179
+ }
180
+
181
+ const resource = new FileResourceProvider(opts.search ?? []);
182
+ const main = await resource.read('/email/main.scss').then(d => d, () => '');
183
+
184
+ if (main) {
185
+ styles.push(main);
186
+ }
187
+
188
+ if (styles.length) {
189
+ const compiled = await this.compileSass(
190
+ { data: styles.join('\n') },
191
+ [...opts.search ?? []]);
192
+
193
+ // Remove all unused styles
194
+ const finalStyles = await this.pruneCss(html, compiled);
195
+
196
+ // Apply styles
197
+ html = await this.inlineCss(html, finalStyles);
198
+ }
199
+
200
+ return html;
201
+ }
202
+
203
+ static async compile(src: MessageCompilationSource): Promise<MessageCompiled> {
204
+ const subject = await this.simplifiedText(await src.subject());
205
+ const text = await this.simplifiedText(await src.text());
206
+
207
+ let html = await src.html();
208
+
209
+ if (src.styles?.inline !== false) {
210
+ html = await this.applyStyles(html, src.styles!);
211
+ }
212
+
213
+ // Fix up html edge cases
214
+ html = this.handleHtmlEdgeCases(html);
215
+
216
+ if (src.images?.inline !== false) {
217
+ html = await this.inlineImages(html, src.images!);
218
+ }
219
+ return { html, subject, text };
220
+ }
221
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
 
3
- import { path } from '@travetto/manifest';
3
+ import { RootIndex, path } from '@travetto/manifest';
4
4
  import { YamlUtil } from '@travetto/yaml';
5
5
 
6
6
  interface ConfigType {
@@ -22,7 +22,7 @@ interface ConfigType {
22
22
  */
23
23
  export class $EditorConfig {
24
24
 
25
- #configFile = path.resolve('resources/email/dev.yml');
25
+ #configFile: Record<string, string> = {};
26
26
  #defaultConfig = {
27
27
  to: 'my-email@gmail.com',
28
28
  from: 'from-email@gmail.com',
@@ -42,9 +42,10 @@ export class $EditorConfig {
42
42
  /**
43
43
  *
44
44
  */
45
- async get(): Promise<ConfigType> {
45
+ async get(file: string): Promise<ConfigType> {
46
46
  try {
47
- const content = await fs.readFile(this.#configFile, 'utf8');
47
+ const mod = RootIndex.getModuleFromSource(file)!.name;
48
+ const content = await fs.readFile(this.#configFile[mod], 'utf8');
48
49
  return YamlUtil.parse<ConfigType>(content);
49
50
  } catch {
50
51
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -52,13 +53,13 @@ export class $EditorConfig {
52
53
  }
53
54
  }
54
55
 
55
- async getContext(): Promise<Exclude<ConfigType['context'], undefined>> {
56
- const conf = await this.get();
56
+ async getContext(file: string): Promise<Exclude<ConfigType['context'], undefined>> {
57
+ const conf = await this.get(file);
57
58
  return conf.context ?? {};
58
59
  }
59
60
 
60
- async getSenderConfig(): Promise<Exclude<ConfigType['sender'], undefined>> {
61
- const conf = await this.get();
61
+ async getSenderConfig(file: string): Promise<Exclude<ConfigType['sender'], undefined>> {
62
+ const conf = await this.get(file);
62
63
  return conf.sender ?? {};
63
64
  }
64
65
 
@@ -66,13 +67,15 @@ export class $EditorConfig {
66
67
  return YamlUtil.serialize(this.#defaultConfig);
67
68
  }
68
69
 
69
- async ensureConfig(): Promise<string> {
70
- const file = this.#configFile;
71
- if (!(await fs.stat(file).catch(() => { }))) {
72
- await fs.mkdir(path.dirname(file), { recursive: true });
73
- await fs.writeFile(file, this.getDefaultConfig(), { encoding: 'utf8' });
70
+ async ensureConfig(file: string): Promise<string> {
71
+ console.log('Ensuring config', file);
72
+ const mod = RootIndex.getModuleFromSource(file)!;
73
+ const resolved = this.#configFile[mod.name] ??= path.resolve(mod.sourcePath, 'resources/email/dev.yml');
74
+ if (!(await fs.stat(resolved).catch(() => { }))) {
75
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
76
+ await fs.writeFile(resolved, this.getDefaultConfig(), { encoding: 'utf8' });
74
77
  }
75
- return file;
78
+ return resolved;
76
79
  }
77
80
  }
78
81
 
@@ -1,9 +1,14 @@
1
+ import { ShutdownManager } from '@travetto/base';
2
+
1
3
  import { EmailCompilationManager } from './manager';
2
4
  import { EditorSendService } from './send';
3
5
  import { EditorConfig } from './config';
4
6
 
7
+ import { EmailCompiler } from '../../src/compiler';
8
+ import { EmailCompileUtil } from '../../src/util';
9
+
5
10
  type InboundMessage =
6
- { type: 'configure' } |
11
+ { type: 'configure', file: string } |
7
12
  { type: 'redraw', file: string } |
8
13
  { type: 'send', file: string, from?: string, to?: string };
9
14
 
@@ -20,20 +25,18 @@ type OutboundMessage =
20
25
  export class EditorState {
21
26
 
22
27
  #lastFile = '';
23
- #sender: EditorSendService;
24
28
  #template: EmailCompilationManager;
25
29
 
26
30
  constructor(template: EmailCompilationManager) {
27
31
  this.#template = template;
28
- this.#sender = new EditorSendService();
29
32
  }
30
33
 
31
34
  async renderFile(file: string): Promise<void> {
32
- file = this.#template.resources.isTemplateFile(file) ? file : this.#lastFile;
35
+ file = EmailCompileUtil.isTemplateFile(file) ? file : this.#lastFile;
33
36
  if (file) {
34
37
  try {
35
38
  const content = await this.#template.resolveCompiledTemplate(
36
- file, await EditorConfig.getContext()
39
+ file, await EditorConfig.getContext(file)
37
40
  );
38
41
  this.response({
39
42
  type: 'changed',
@@ -57,12 +60,12 @@ export class EditorState {
57
60
  }
58
61
 
59
62
  async onConfigure(msg: InboundMessage & { type: 'configure' }): Promise<void> {
60
- this.response({ type: 'configured', file: await EditorConfig.ensureConfig() });
63
+ this.response({ type: 'configured', file: await EditorConfig.ensureConfig(msg.file) });
61
64
  }
62
65
 
63
66
  async #onRedraw(msg: InboundMessage & { type: 'redraw' }): Promise<void> {
64
67
  try {
65
- await this.#template.compiler.compile(msg.file, true);
68
+ await EmailCompiler.compile(msg.file, true);
66
69
  await this.renderFile(msg.file);
67
70
  } catch (err) {
68
71
  if (err && err instanceof Error) {
@@ -74,12 +77,15 @@ export class EditorState {
74
77
  }
75
78
 
76
79
  async onSend(msg: InboundMessage & { type: 'send' }): Promise<void> {
77
- const cfg = await EditorConfig.get();
80
+ const cfg = await EditorConfig.get(msg.file);
78
81
  const to = msg.to || cfg.to;
79
82
  const from = msg.from || cfg.from;
80
- const key = this.#template.resources.buildOutputPath(msg.file, '');
83
+ const content = await this.#template.resolveCompiledTemplate(
84
+ msg.file, await EditorConfig.getContext(msg.file)
85
+ );
86
+
81
87
  try {
82
- const url = await this.#sender.sendEmail(key, from, to, await EditorConfig.getContext());
88
+ const url = await EditorSendService.sendEmail(msg.file, { from, to, ...content, });
83
89
  this.response({ type: 'sent', to, file: msg.file, ...url });
84
90
  } catch (err) {
85
91
  if (err && err instanceof Error) {
@@ -101,7 +107,11 @@ export class EditorState {
101
107
  case 'send': this.onSend(msg); break;
102
108
  }
103
109
  });
104
- for await (const f of this.#template.watchCompile()) {
110
+
111
+ process.on('disconnect', () => ShutdownManager.execute());
112
+ process.send?.('ready');
113
+
114
+ for await (const f of EmailCompiler.watchCompile()) {
105
115
  await this.renderFile(f);
106
116
  }
107
117
  }
@@ -1,15 +1,11 @@
1
+ import fs from 'fs/promises';
2
+
1
3
  import type { MailTemplateEngine, MessageCompiled } from '@travetto/email';
2
- import { TypedObject } from '@travetto/base';
3
4
  import { DependencyRegistry } from '@travetto/di';
4
- import { RootIndex, WatchEvent, WatchStream } from '@travetto/manifest';
5
-
5
+ import { TypedObject } from '@travetto/base';
6
6
  import { MailTemplateEngineTarget } from '@travetto/email/src/internal/types';
7
- import { DynamicFileLoader } from '@travetto/base/src/internal/file-loader';
8
7
 
9
- import type { EmailCompiler } from '../../src/compiler';
10
- import type { EmailCompilerResource } from '../../src/resource';
11
-
12
- const VALID_FILE = (file: string): boolean => /[.](scss|css|png|jpe?g|gif|ya?ml)$/.test(file) && !/[.]compiled[.]/.test(file);
8
+ import { EmailCompiler } from '../../src/compiler';
13
9
 
14
10
  /**
15
11
  *
@@ -17,41 +13,31 @@ const VALID_FILE = (file: string): boolean => /[.](scss|css|png|jpe?g|gif|ya?ml)
17
13
  export class EmailCompilationManager {
18
14
 
19
15
  static async createInstance(): Promise<EmailCompilationManager> {
20
- const { EmailCompiler: Compiler } = await import('../../src/compiler.js');
21
- const { EmailCompilerResource: Res } = await import('../../src/resource.js');
22
-
23
16
  return new EmailCompilationManager(
24
17
  await DependencyRegistry.getInstance<MailTemplateEngine>(MailTemplateEngineTarget),
25
- new Compiler(new Res())
26
18
  );
27
19
  }
28
20
 
29
- compiler: EmailCompiler;
30
21
  engine: MailTemplateEngine;
31
22
 
32
- constructor(engine: MailTemplateEngine, compiler: EmailCompiler) {
23
+ constructor(engine: MailTemplateEngine) {
33
24
  this.engine = engine;
34
- this.compiler = compiler;
35
- }
36
-
37
- get resources(): EmailCompilerResource {
38
- return this.compiler.resources;
39
25
  }
40
26
 
41
27
  /**
42
28
  * Resolve template
43
29
  */
44
30
  async resolveTemplateParts(file: string): Promise<MessageCompiled> {
45
- const files = this.resources.getOutputs(file);
46
- const missing = await Promise.all(Object.values(files).map(x => this.resources.describe(x).catch(() => { })));
31
+ const files = EmailCompiler.getOutputFiles(file);
32
+ const missing = await Promise.all(Object.values(files).map(x => fs.stat(file).catch(() => { })));
47
33
 
48
34
  if (missing.some(x => x === undefined)) {
49
- await this.compiler.compile(file, true);
35
+ await EmailCompiler.compile(file, true);
50
36
  }
51
37
 
52
38
  const parts = await Promise.all(
53
39
  TypedObject.entries(files).map(
54
- ([key, subRel]) => this.resources.read(subRel)
40
+ ([key, partFile]) => fs.readFile(partFile, 'utf8')
55
41
  .then(content => [key, content] as const)
56
42
  )
57
43
  );
@@ -72,40 +58,4 @@ export class EmailCompilationManager {
72
58
  };
73
59
  }
74
60
 
75
- /**
76
- * Watch compilation
77
- */
78
- async * watchCompile(): AsyncIterable<string> {
79
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
80
- const stream = this.resources.watchFiles() as (
81
- WatchStream & {
82
- add(item: WatchEvent | WatchEvent[]): void;
83
- }
84
- );
85
- DynamicFileLoader.onLoadEvent((ev) => {
86
- const src = RootIndex.getEntry(ev.file);
87
- if (src && this.resources.isTemplateFile(src.sourceFile)) {
88
- stream.add({ ...ev, file: src.sourceFile });
89
- }
90
- });
91
- for await (const { file, action } of stream) {
92
- if (action === 'delete') {
93
- continue;
94
- }
95
-
96
- try {
97
- if (this.resources.isTemplateFile(file)) {
98
- await this.compiler.compile(file, true);
99
- yield file;
100
- } else if (VALID_FILE(file)) {
101
- await this.compiler.compileAll(true);
102
- for (const el of await this.resources.findAllTemplates()) {
103
- yield el.file!;
104
- }
105
- }
106
- } catch (err) {
107
- console.error(`Error in compiling ${file}`, err && err instanceof Error ? err.message : `${err}`);
108
- }
109
- }
110
- }
111
61
  }
@@ -1,22 +1,25 @@
1
- import { MailService, SentMessage } from '@travetto/email';
1
+ import { MailService, MessageOptions, SentMessage } from '@travetto/email';
2
2
  import { MailTransportTarget } from '@travetto/email/src/internal/types';
3
3
  import { DependencyRegistry } from '@travetto/di';
4
4
 
5
5
  import { EditorConfig } from './config';
6
+ import { RootIndex } from '@travetto/manifest';
6
7
 
7
8
  /**
8
9
  * Util for sending emails
9
10
  */
10
11
  export class EditorSendService {
11
12
 
12
- #svc: MailService;
13
+ static #svc: Record<string, MailService> = {};
13
14
 
14
15
  /**
15
16
  * Get mail service
16
17
  */
17
- async getMailService(): Promise<MailService> {
18
- if (!this.#svc) {
19
- const senderConfig = await EditorConfig.getSenderConfig();
18
+ static async getMailService(file: string): Promise<MailService> {
19
+ const mod = RootIndex.getModuleFromSource(file)!.name;
20
+
21
+ if (!this.#svc[mod]) {
22
+ const senderConfig = await EditorConfig.getSenderConfig(file);
20
23
 
21
24
  if (senderConfig?.host?.includes('ethereal.email')) {
22
25
  const cls = class { };
@@ -39,30 +42,32 @@ ${EditorConfig.getDefaultConfig()}`.trim();
39
42
  throw new Error(errorMessage);
40
43
  }
41
44
 
42
- this.#svc = await DependencyRegistry.getInstance(MailService);
45
+ this.#svc[mod] = await DependencyRegistry.getInstance(MailService);
43
46
  }
44
- return this.#svc;
47
+ return this.#svc[mod];
45
48
  }
46
49
 
47
50
  /**
48
51
  * Resolve template
49
52
  */
50
- async sendEmail(key: string, from: string, to: string, context: Record<string, unknown>): Promise<{
53
+ static async sendEmail(file: string, message: MessageOptions): Promise<{
51
54
  url?: string | false;
52
55
  }> {
56
+ const to = message.to!;
53
57
  try {
54
58
  console.log('Sending email', { to });
55
59
  // Let the engine template
56
- const svc = await this.getMailService();
60
+ const svc = await this.getMailService(file);
57
61
  if (!svc) {
58
62
  throw new Error('Node mailer support is missing');
59
63
  }
60
64
 
61
- const info = await svc.send<{ host?: string } & SentMessage>({ to, from }, key, context);
65
+ const info = await svc.send<{ host?: string } & SentMessage>(message);
62
66
  console.log('Sent email', { to });
63
67
 
64
- const senderConfig = await EditorConfig.getSenderConfig();
68
+ const senderConfig = await EditorConfig.getSenderConfig(file);
65
69
  return senderConfig.host?.includes('ethereal.email') ? {
70
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
66
71
  url: (await import('nodemailer')).getTestMessageUrl(info as any)
67
72
  } : {};
68
73
  } catch (err) {
@@ -1,13 +1,13 @@
1
1
  import { RootRegistry } from '@travetto/registry';
2
2
  import { CliCommandShape, CliCommand, cliTpl } from '@travetto/cli';
3
-
4
- import { EmailCompilationManager } from './bin/manager';
5
3
  import { GlobalEnvConfig } from '@travetto/base';
6
4
 
5
+ import { EmailCompiler } from '../src/compiler';
6
+
7
7
  /**
8
8
  * CLI Entry point for running the email server
9
9
  */
10
- @CliCommand({ fields: ['module'] })
10
+ @CliCommand()
11
11
  export class EmailCompileCommand implements CliCommandShape {
12
12
 
13
13
  /** Compile in watch mode */
@@ -23,14 +23,16 @@ export class EmailCompileCommand implements CliCommandShape {
23
23
  async main(): Promise<void> {
24
24
  await RootRegistry.init();
25
25
 
26
- const template = await EmailCompilationManager.createInstance();
27
-
28
26
  // Let the engine template
29
- const all = await template.compiler.compileAll(true);
27
+ const all = await EmailCompiler.compileAll();
30
28
  console!.log(cliTpl`Successfully compiled ${{ param: `${all.length}` }} templates`);
29
+ for (const el of all) {
30
+ console!.log(cliTpl` * ${{ param: el }}`);
31
+ }
31
32
 
32
33
  if (this.watch) {
33
- for await (const _ of template.watchCompile()) {
34
+
35
+ for await (const _ of EmailCompiler.watchCompile()) {
34
36
  // Iterate until done
35
37
  }
36
38
  }
@@ -1,6 +1,5 @@
1
- import { GlobalEnvConfig, ShutdownManager } from '@travetto/base';
1
+ import { GlobalEnvConfig } from '@travetto/base';
2
2
  import { CliCommand } from '@travetto/cli';
3
- import { RootIndex } from '@travetto/manifest';
4
3
  import { RootRegistry } from '@travetto/registry';
5
4
 
6
5
  import { EditorState } from './bin/editor';
@@ -11,19 +10,11 @@ import { EmailCompilationManager } from './bin/manager';
11
10
  export class EmailEditorCommand {
12
11
 
13
12
  envInit(): GlobalEnvConfig {
14
- return {
15
- envName: 'dev',
16
- resourcePaths: [`${RootIndex.getModule('@travetto/email-compiler')!.sourcePath}/resources`]
17
- };
13
+ return { envName: 'dev', dynamic: true };
18
14
  }
19
15
 
20
16
  async main(): Promise<void> {
21
17
  await RootRegistry.init();
22
- const editor = new EditorState(await EmailCompilationManager.createInstance());
23
- await editor.init();
24
- if (process.send) {
25
- process.on('disconnect', () => ShutdownManager.execute());
26
- process.send('ready');
27
- }
18
+ await new EditorState(await EmailCompilationManager.createInstance()).init();
28
19
  }
29
20
  }
@@ -4,7 +4,10 @@ import { CliCommandShape, CliCommand } from '@travetto/cli';
4
4
  import { GlobalEnvConfig } from '@travetto/base';
5
5
 
6
6
  import { EmailCompilationManager } from './bin/manager';
7
- import { EditorState } from './bin/editor';
7
+ import { EditorConfig } from './bin/config';
8
+ import { EditorSendService } from './bin/send';
9
+
10
+ import { EmailCompiler } from '../src/compiler';
8
11
 
9
12
  /**
10
13
  * CLI Entry point for running the email server
@@ -19,10 +22,12 @@ export class EmailTestCommand implements CliCommandShape {
19
22
  async main(file: string, to: string): Promise<void> {
20
23
  file = path.resolve(file);
21
24
  await RootRegistry.init();
22
- const template = await EmailCompilationManager.createInstance();
23
- await template.compiler.compile(file, true);
24
- const editor = new EditorState(await EmailCompilationManager.createInstance());
25
- await editor.onConfigure({ type: 'configure' });
26
- await editor.onSend({ type: 'send', file, to });
25
+ await EmailCompiler.compile(file, true);
26
+
27
+ const mgr = await EmailCompilationManager.createInstance();
28
+ const cfg = await EditorConfig.get(file);
29
+ const content = await mgr.resolveCompiledTemplate(file, await EditorConfig.getContext(file));
30
+
31
+ await EditorSendService.sendEmail(file, { from: cfg.from, to, ...content, });
27
32
  }
28
33
  }
package/src/resource.ts DELETED
@@ -1,111 +0,0 @@
1
- import { FileQueryProvider } from '@travetto/base';
2
- import { MessageCompilationSource, MessageCompiled } from '@travetto/email';
3
- import { RootIndex, path } from '@travetto/manifest';
4
-
5
- /**
6
- * Resource management for email templating
7
- */
8
- export class EmailCompilerResource extends FileQueryProvider {
9
- static PATH_PREFIX = /.*\/resources\//;
10
- static EXT = /[.]email[.][jt]sx$/;
11
-
12
- get ext(): RegExp {
13
- return EmailCompilerResource.EXT;
14
- }
15
-
16
- constructor(paths: string[] = ['@travetto/email-compiler#support/resources']) {
17
- super({ paths, includeCommon: true });
18
- }
19
-
20
- buildOutputPath(file: string, suffix: string, prefix?: string): string {
21
- let res = file.replace(/.*(support|src)\//, '').replace(this.ext, suffix);
22
- if (prefix) {
23
- res = path.join(prefix, res);
24
- }
25
- return res;
26
- }
27
-
28
- /**
29
- * Get the different parts from the file name
30
- * @returns
31
- */
32
- getOutputs(file: string, prefix?: string): MessageCompiled {
33
- return {
34
- html: this.buildOutputPath(file, '.compiled.html', prefix),
35
- subject: this.buildOutputPath(file, '.compiled.subject', prefix),
36
- text: this.buildOutputPath(file, '.compiled.text', prefix),
37
- };
38
- }
39
-
40
- /**
41
- * Is this a valid template file?
42
- */
43
- isTemplateFile(file: string): boolean {
44
- return this.ext.test(file);
45
- }
46
-
47
- /**
48
- * Get the sending email key from a template file
49
- * @param file
50
- */
51
- async templateFileToKey(file: string): Promise<string> {
52
- return this.buildOutputPath(file, '');
53
- }
54
-
55
- async loadTemplate(imp: string): Promise<MessageCompilationSource> {
56
- const entry = RootIndex.getEntry(imp) ?? RootIndex.getFromImport(imp);
57
- if (!entry) {
58
- throw new Error();
59
- }
60
- const root = (await import(entry.outputFile)).default;
61
- return { ...await root.wrap(), file: entry.sourceFile };
62
- }
63
-
64
- /**
65
- * Grab list of all available templates
66
- */
67
- async findAllTemplates(): Promise<MessageCompilationSource[]> {
68
- const items = RootIndex.findSupport({
69
- filter: (f) => this.ext.test(f)
70
- });
71
- const out: Promise<MessageCompilationSource>[] = [];
72
- for (const item of items) {
73
- out.push(this.loadTemplate(item.import));
74
- }
75
- return Promise.all(out);
76
- }
77
-
78
-
79
- /**
80
- * Run through text and match/resolve resource urls, producing tokens
81
- *
82
- * @param text
83
- * @param patterns
84
- * @returns
85
- */
86
- async tokenizeResources(
87
- text: string,
88
- patterns: RegExp[]
89
- ): Promise<{
90
- text: string;
91
- tokens: Map<string, string>;
92
- finalize: (onToken: (token: string) => string) => string;
93
- }> {
94
- let id = 0;
95
- const tokens = new Map();
96
- for (const pattern of patterns) {
97
- for (const { [0]: all, groups: { pre, src } = { pre: '', src: '' } } of text.matchAll(pattern)) {
98
- if (src.includes('://')) { // No urls
99
- continue;
100
- }
101
- await this.describe(src);
102
- const token = `@@${id += 1}@@`;
103
- tokens.set(token, src);
104
- text = text.replace(all, `${pre}${token}`);
105
- }
106
- }
107
- const finalize = (onToken: (token: string) => string): string => text.replace(/@@[^@]+@@/g, t => onToken(t));
108
-
109
- return { text, tokens, finalize };
110
- }
111
- }
File without changes