nuxt-generation-emails 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -56,42 +56,7 @@ export default defineNuxtConfig({
56
56
 
57
57
  ---
58
58
 
59
- ## 📁 2. Folder Structure
60
-
61
- Templates live inside your app's source directory under the configured `emailDir` (default: `emails/`). You can nest them in subdirectories to organize by version, category, or however you like.
62
-
63
- ```
64
- app/
65
- emails/
66
- v1/
67
- order-confirmation.vue
68
- welcome.vue
69
- v2/
70
- order-confirmation.vue
71
- marketing/
72
- promo.vue
73
- ```
74
-
75
- The directory structure maps directly to routes:
76
-
77
- | Template file | Preview URL | API endpoint |
78
- |----------------------------------------|-------------------------------------------|------------------------------------------|
79
- | `emails/v1/order-confirmation.vue` | `/__emails/v1/order-confirmation` | `POST /api/emails/v1/order-confirmation` |
80
- | `emails/v1/welcome.vue` | `/__emails/v1/welcome` | `POST /api/emails/v1/welcome` |
81
- | `emails/v2/order-confirmation.vue` | `/__emails/v2/order-confirmation` | `POST /api/emails/v2/order-confirmation` |
82
- | `emails/marketing/promo.vue` | `/__emails/marketing/promo` | `POST /api/emails/marketing/promo` |
83
-
84
- ### Generated routes overview
85
-
86
- ![Overview of all auto-generated API routes](images/example-overview.png)
87
-
88
- ### Example route detail
89
-
90
- ![Detail view of a specific email API route](images/api-post-example.png)
91
-
92
- ---
93
-
94
- ## 🛠️ 3. Adding Templates with the CLI
59
+ ## �️ 2. Adding Templates with the CLI
95
60
 
96
61
  The fastest way to create a new email template:
97
62
 
@@ -172,6 +137,41 @@ If the dev server is running, it will automatically detect the new file and rest
172
137
 
173
138
  ---
174
139
 
140
+ ## 📁 3. Folder Structure
141
+
142
+ Templates live inside your app's source directory under the configured `emailDir` (default: `emails/`). You can nest them in subdirectories to organize by version, category, or however you like.
143
+
144
+ ```
145
+ app/
146
+ emails/
147
+ v1/
148
+ order-confirmation.vue
149
+ welcome.vue
150
+ v2/
151
+ order-confirmation.vue
152
+ marketing/
153
+ promo.vue
154
+ ```
155
+
156
+ The directory structure maps directly to routes:
157
+
158
+ | Template file | Preview URL | API endpoint |
159
+ |----------------------------------------|-------------------------------------------|------------------------------------------|
160
+ | `emails/v1/order-confirmation.vue` | `/__emails/v1/order-confirmation` | `POST /api/emails/v1/order-confirmation` |
161
+ | `emails/v1/welcome.vue` | `/__emails/v1/welcome` | `POST /api/emails/v1/welcome` |
162
+ | `emails/v2/order-confirmation.vue` | `/__emails/v2/order-confirmation` | `POST /api/emails/v2/order-confirmation` |
163
+ | `emails/marketing/promo.vue` | `/__emails/marketing/promo` | `POST /api/emails/marketing/promo` |
164
+
165
+ ### Generated routes overview
166
+
167
+ ![Overview of all auto-generated API routes](images/example-overview.png)
168
+
169
+ ### Example route detail
170
+
171
+ ![Detail view of a specific email API route](images/api-post-example.png)
172
+
173
+ ---
174
+
175
175
  ## ✍️ 4. Writing Email Templates
176
176
 
177
177
  Templates are standard Vue SFCs using [`@vue-email/components`](https://vuemail.net/). Define your template's dynamic data using `defineProps` with `withDefaults`:
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.0.0"
6
6
  },
7
- "version": "0.2.02",
7
+ "version": "0.2.4",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -5,30 +5,101 @@ import { join, relative } from 'pathe';
5
5
  import { consola } from 'consola';
6
6
  import { parse, compileScript } from 'vue/compiler-sfc';
7
7
 
8
- function generateWrapperComponent(emailsLayoutPath, emailComponentPath, extractedProps) {
9
- const hasProps = extractedProps.props.length > 0;
10
- const defaultsLiteral = JSON.stringify(extractedProps.defaults, null, 2);
11
- const propDefsLiteral = JSON.stringify(
12
- extractedProps.props.map((p) => ({ name: p.name, type: p.type })),
13
- null,
14
- 2
15
- );
8
+ function generateWrapperComponent(emailsLayoutPath, emailComponentPath) {
16
9
  const scriptClose = "<\/script>";
17
10
  const templateOpen = "<template>";
18
11
  const templateClose = "</template>";
19
12
  return `<script setup lang="ts">
20
- import { reactive, definePageMeta, onMounted } from '#imports'
13
+ import { reactive, computed, watch, shallowRef, triggerRef, definePageMeta, onMounted } from '#imports'
21
14
  import EmailsLayout from '${emailsLayoutPath}'
22
- import EmailComponent from '${emailComponentPath}'
15
+ import EmailComponentRaw from '${emailComponentPath}'
23
16
 
24
17
  definePageMeta({ layout: false })
25
- ${hasProps ? `
26
- const propDefaults = ${defaultsLiteral}
27
- const propDefinitions = ${propDefsLiteral}
28
18
 
29
- // Reactive state derived from the template's withDefaults \u2014 drives both
30
- // the live preview and the sidebar controls.
31
- const emailProps = reactive<Record<string, unknown>>({ ...propDefaults })
19
+ // ---- Runtime component introspection ----
20
+ // We read prop definitions from the compiled component's .props object at
21
+ // runtime. Vue's HMR runtime mutates .props in-place (same object ref) via
22
+ // Object.assign, so a shallowRef change won't fire. We use triggerRef() on
23
+ // every HMR update to force the computed to re-evaluate.
24
+
25
+ const emailComponent = shallowRef(EmailComponentRaw)
26
+
27
+ if (import.meta.hot) {
28
+ // vite:afterUpdate fires client-side after ANY HMR update is applied.
29
+ // triggerRef is cheap \u2014 the computed just re-reads .props from the
30
+ // already-mutated component object.
31
+ import.meta.hot.on('vite:afterUpdate', () => {
32
+ triggerRef(emailComponent)
33
+ })
34
+ }
35
+
36
+ function inferType(ctor: unknown): 'string' | 'number' | 'boolean' | 'object' | 'unknown' {
37
+ if (ctor === String) return 'string'
38
+ if (ctor === Number) return 'number'
39
+ if (ctor === Boolean) return 'boolean'
40
+ if (ctor === Object || ctor === Array) return 'object'
41
+ return 'unknown'
42
+ }
43
+
44
+ interface PropDefinition {
45
+ name: string
46
+ type: 'string' | 'number' | 'boolean' | 'object' | 'unknown'
47
+ }
48
+
49
+ const introspected = computed(() => {
50
+ const comp = emailComponent.value as any
51
+ const raw = comp?.props
52
+ const defs: PropDefinition[] = []
53
+ const defaults: Record<string, unknown> = {}
54
+
55
+ if (!raw) return { defs, defaults }
56
+
57
+ if (Array.isArray(raw)) {
58
+ for (const name of raw) {
59
+ defs.push({ name, type: 'unknown' })
60
+ }
61
+ } else {
62
+ for (const [name, spec] of Object.entries(raw as Record<string, any>)) {
63
+ const ctor = spec?.type ?? spec
64
+ defs.push({ name, type: inferType(ctor) })
65
+
66
+ if (spec?.default !== undefined) {
67
+ defaults[name] = typeof spec.default === 'function' ? spec.default() : spec.default
68
+ }
69
+ }
70
+ }
71
+
72
+ return { defs, defaults }
73
+ })
74
+
75
+ const propDefinitions = computed(() => introspected.value.defs)
76
+
77
+ // Reactive state that drives both the live preview and the sidebar controls.
78
+ const emailProps = reactive<Record<string, unknown>>({})
79
+
80
+ // Whenever the component's props change (HMR), reconcile the reactive state:
81
+ // add new props with their defaults, remove props that no longer exist.
82
+ watch(
83
+ () => introspected.value,
84
+ ({ defs, defaults }) => {
85
+ const validNames = new Set(defs.map(d => d.name))
86
+
87
+ // Add new props
88
+ for (const name of validNames) {
89
+ if (!(name in emailProps)) {
90
+ emailProps[name] = defaults[name] ?? undefined
91
+ }
92
+ }
93
+
94
+ // Remove stale props
95
+ for (const key of Object.keys(emailProps)) {
96
+ if (!validNames.has(key)) {
97
+ delete emailProps[key]
98
+ }
99
+ }
100
+ },
101
+ { immediate: true },
102
+ )
32
103
 
33
104
  // Hydrate from URL params on mount so shared links restore state.
34
105
  onMounted(() => {
@@ -49,111 +120,16 @@ onMounted(() => {
49
120
  })
50
121
  }
51
122
  })
52
- ` : ""}
53
123
  ${scriptClose}
54
124
 
55
125
  ${templateOpen}
56
- <EmailsLayout${hasProps ? ' :email-props="emailProps" :prop-definitions="propDefinitions"' : ""}>
57
- <EmailComponent${hasProps ? ' v-bind="emailProps"' : ""} />
126
+ <EmailsLayout :email-props="emailProps" :prop-definitions="propDefinitions">
127
+ <component :is="emailComponent" v-bind="emailProps" />
58
128
  </EmailsLayout>
59
129
  ${templateClose}
60
130
  `;
61
131
  }
62
132
 
63
- function extractPropsFromSFC(filePath) {
64
- const source = fs.readFileSync(filePath, "utf-8");
65
- const { descriptor } = parse(source, { filename: filePath });
66
- if (!descriptor.scriptSetup) {
67
- return { props: [], defaults: {} };
68
- }
69
- try {
70
- const compiled = compileScript(descriptor, {
71
- id: filePath,
72
- isProd: false
73
- });
74
- const props = [];
75
- const defaults = {};
76
- const propNames = [];
77
- if (compiled.bindings) {
78
- for (const [name, binding] of Object.entries(compiled.bindings)) {
79
- if (binding === "props") {
80
- propNames.push(name);
81
- }
82
- }
83
- }
84
- const propTypes = extractPropTypesFromSource(descriptor.scriptSetup.content);
85
- for (const name of propNames) {
86
- const type = propTypes[name] ?? "unknown";
87
- props.push({ name, type });
88
- }
89
- const defaultValues = extractDefaultsFromSource(descriptor.scriptSetup.content);
90
- for (const [name, value] of Object.entries(defaultValues)) {
91
- defaults[name] = value;
92
- const existing = props.find((p) => p.name === name);
93
- if (existing) {
94
- existing.default = value;
95
- }
96
- }
97
- return { props, defaults };
98
- } catch {
99
- return { props: [], defaults: {} };
100
- }
101
- }
102
- function extractPropTypesFromSource(scriptContent) {
103
- const result = {};
104
- const match = scriptContent.match(/defineProps\s*<\s*\{([\s\S]*?)\}\s*>\s*\(/);
105
- if (!match) return result;
106
- const typeBody = match[1];
107
- const propPattern = /(\w+)\s*(?:\?\s*)?:\s*(\w+)/g;
108
- let propMatch;
109
- while ((propMatch = propPattern.exec(typeBody)) !== null) {
110
- const name = propMatch[1];
111
- const tsType = propMatch[2];
112
- switch (tsType.toLowerCase()) {
113
- case "string":
114
- result[name] = "string";
115
- break;
116
- case "number":
117
- result[name] = "number";
118
- break;
119
- case "boolean":
120
- result[name] = "boolean";
121
- break;
122
- default:
123
- result[name] = "object";
124
- break;
125
- }
126
- }
127
- return result;
128
- }
129
- function extractDefaultsFromSource(scriptContent) {
130
- const withDefaultsMatch = scriptContent.match(
131
- /withDefaults\s*\(\s*defineProps\s*<[^>]*>\s*\(\s*\)\s*,\s*(\{[\s\S]*?\})\s*\)/
132
- );
133
- if (!withDefaultsMatch) return {};
134
- const defaultsText = withDefaultsMatch[1];
135
- if (!defaultsText) return {};
136
- try {
137
- const jsonish = defaultsText.replace(/'/g, '"').replace(/(\w+)\s*:/g, '"$1":').replace(/,\s*([\]}])/g, "$1");
138
- return JSON.parse(jsonish);
139
- } catch {
140
- return extractPrimitivesFromObjectLiteral(defaultsText);
141
- }
142
- }
143
- function extractPrimitivesFromObjectLiteral(text) {
144
- const result = {};
145
- const linePattern = /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|(\d+(?:\.\d+)?)|(true|false))\s*[,}]?/g;
146
- let match;
147
- while ((match = linePattern.exec(text)) !== null) {
148
- const key = match[1];
149
- if (match[2] != null) result[key] = match[2];
150
- else if (match[3] != null) result[key] = match[3];
151
- else if (match[4] != null) result[key] = Number(match[4]);
152
- else if (match[5] != null) result[key] = match[5] === "true";
153
- }
154
- return result;
155
- }
156
-
157
133
  function addEmailPages(dirPath, pages, options, routePrefix = "") {
158
134
  const entries = fs.readdirSync(dirPath);
159
135
  for (const entry of entries) {
@@ -169,11 +145,9 @@ function addEmailPages(dirPath, pages, options, routePrefix = "") {
169
145
  if (!fs.existsSync(wrapperDir)) {
170
146
  fs.mkdirSync(wrapperDir, { recursive: true });
171
147
  }
172
- const extractedProps = extractPropsFromSFC(fullPath);
173
148
  const wrapperContent = generateWrapperComponent(
174
149
  options.emailTemplateComponentPath,
175
- fullPath,
176
- extractedProps
150
+ fullPath
177
151
  );
178
152
  fs.writeFileSync(wrapperPath, wrapperContent, "utf-8");
179
153
  const pageName = `email${routePrefix.replace(/\//g, "-")}-${name}`.replace(/^-+/, "");
@@ -303,6 +277,100 @@ export default defineEventHandler(async (event) => {
303
277
  `;
304
278
  }
305
279
 
280
+ function extractPropsFromSFC(filePath) {
281
+ const source = fs.readFileSync(filePath, "utf-8");
282
+ const { descriptor } = parse(source, { filename: filePath });
283
+ if (!descriptor.scriptSetup) {
284
+ return { props: [], defaults: {} };
285
+ }
286
+ try {
287
+ const compiled = compileScript(descriptor, {
288
+ id: filePath,
289
+ isProd: false
290
+ });
291
+ const props = [];
292
+ const defaults = {};
293
+ const propNames = [];
294
+ if (compiled.bindings) {
295
+ for (const [name, binding] of Object.entries(compiled.bindings)) {
296
+ if (binding === "props") {
297
+ propNames.push(name);
298
+ }
299
+ }
300
+ }
301
+ const propTypes = extractPropTypesFromSource(descriptor.scriptSetup.content);
302
+ for (const name of propNames) {
303
+ const type = propTypes[name] ?? "unknown";
304
+ props.push({ name, type });
305
+ }
306
+ const defaultValues = extractDefaultsFromSource(descriptor.scriptSetup.content);
307
+ for (const [name, value] of Object.entries(defaultValues)) {
308
+ defaults[name] = value;
309
+ const existing = props.find((p) => p.name === name);
310
+ if (existing) {
311
+ existing.default = value;
312
+ }
313
+ }
314
+ return { props, defaults };
315
+ } catch {
316
+ return { props: [], defaults: {} };
317
+ }
318
+ }
319
+ function extractPropTypesFromSource(scriptContent) {
320
+ const result = {};
321
+ const match = scriptContent.match(/defineProps\s*<\s*\{([\s\S]*?)\}\s*>\s*\(/);
322
+ if (!match) return result;
323
+ const typeBody = match[1];
324
+ const propPattern = /(\w+)\s*(?:\?\s*)?:\s*(\w+)/g;
325
+ let propMatch;
326
+ while ((propMatch = propPattern.exec(typeBody)) !== null) {
327
+ const name = propMatch[1];
328
+ const tsType = propMatch[2];
329
+ switch (tsType.toLowerCase()) {
330
+ case "string":
331
+ result[name] = "string";
332
+ break;
333
+ case "number":
334
+ result[name] = "number";
335
+ break;
336
+ case "boolean":
337
+ result[name] = "boolean";
338
+ break;
339
+ default:
340
+ result[name] = "object";
341
+ break;
342
+ }
343
+ }
344
+ return result;
345
+ }
346
+ function extractDefaultsFromSource(scriptContent) {
347
+ const withDefaultsMatch = scriptContent.match(
348
+ /withDefaults\s*\(\s*defineProps\s*<[^>]*>\s*\(\s*\)\s*,\s*(\{[\s\S]*?\})\s*\)/
349
+ );
350
+ if (!withDefaultsMatch) return {};
351
+ const defaultsText = withDefaultsMatch[1];
352
+ if (!defaultsText) return {};
353
+ try {
354
+ const jsonish = defaultsText.replace(/'/g, '"').replace(/(\w+)\s*:/g, '"$1":').replace(/,\s*([\]}])/g, "$1");
355
+ return JSON.parse(jsonish);
356
+ } catch {
357
+ return extractPrimitivesFromObjectLiteral(defaultsText);
358
+ }
359
+ }
360
+ function extractPrimitivesFromObjectLiteral(text) {
361
+ const result = {};
362
+ const linePattern = /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|(\d+(?:\.\d+)?)|(true|false))\s*[,}]?/g;
363
+ let match;
364
+ while ((match = linePattern.exec(text)) !== null) {
365
+ const key = match[1];
366
+ if (match[2] != null) result[key] = match[2];
367
+ else if (match[3] != null) result[key] = match[3];
368
+ else if (match[4] != null) result[key] = Number(match[4]);
369
+ else if (match[5] != null) result[key] = match[5] === "true";
370
+ }
371
+ return result;
372
+ }
373
+
306
374
  function generateServerRoutes(emailsDir, buildDir) {
307
375
  if (!fs.existsSync(emailsDir)) return [];
308
376
  const handlersDir = join(buildDir, "email-handlers");
@@ -432,7 +500,7 @@ declare module 'nitropack/types' {
432
500
  const rollupConfig = nuxt.options.nitro?.rollupConfig ?? {};
433
501
  const existingPlugins = rollupConfig.plugins ?? [];
434
502
  const plugins = Array.isArray(existingPlugins) ? existingPlugins : [existingPlugins];
435
- rollupConfig.plugins = [...plugins, vue()];
503
+ rollupConfig.plugins = [vue(), ...plugins];
436
504
  nuxt.options.nitro = { ...nuxt.options.nitro, rollupConfig };
437
505
  if (nuxt.options.dev && fs.existsSync(emailsDir)) {
438
506
  const relDir = relative(nuxt.options.rootDir, emailsDir);
@@ -5,7 +5,7 @@ interface PropDefinition {
5
5
  type __VLS_Props = {
6
6
  /** Reactive object containing current prop values (managed by the wrapper) */
7
7
  emailProps?: Record<string, unknown>;
8
- /** Flat list of prop definitions extracted from the SFC at build time */
8
+ /** Flat list of prop definitions derived from the component at runtime */
9
9
  propDefinitions?: PropDefinition[];
10
10
  };
11
11
  declare var __VLS_1: {}, __VLS_19: {};
@@ -5,7 +5,7 @@ interface PropDefinition {
5
5
  type __VLS_Props = {
6
6
  /** Reactive object containing current prop values (managed by the wrapper) */
7
7
  emailProps?: Record<string, unknown>;
8
- /** Flat list of prop definitions extracted from the SFC at build time */
8
+ /** Flat list of prop definitions derived from the component at runtime */
9
9
  propDefinitions?: PropDefinition[];
10
10
  };
11
11
  declare var __VLS_1: {}, __VLS_19: {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-generation-emails",
3
- "version": "0.2.02",
3
+ "version": "0.2.4",
4
4
  "description": "A Nuxt module for authoring, previewing, and sending transactional email templates with Vue Email.",
5
5
  "author": "nullcarry@icloud.com",
6
6
  "repository": {