@lupinum/nuxt-email 0.1.0 → 1.0.0-beta.1

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 CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.0-beta.1
4
+
5
+ Status: certified beta candidate. Feature development is frozen until stable `1.0.0`; manual email-client QA and the external transactional beta remain pending.
6
+
7
+ ### Added
8
+
9
+ - Added synchronous `subject` and authored plain-text `text` metadata to `defineEmail`, including prop-derived functions. Authored text takes precedence over automatic HTML-to-text derivation.
10
+ - Added the type-only `EmailComponentProps<Component>` helper for deterministic, component-checked preview fixtures.
11
+ - Added a baseURL-aware Nuxt DevTools shortcut to the existing development-only `/__email` preview.
12
+ - Added direct Resend and local Nodemailer/Mailpit sending recipes while keeping all delivery behavior application-owned.
13
+
14
+ ### Changed
15
+
16
+ - Rebuilt Tailwind v4 nested-selector and nested `@media`/`@supports` handling so declarations and rules preserve source order, media ranges are downleveled, and unhandled nesting fails instead of emitting raw `&` CSS.
17
+ - Limited emitted keyframes to animations referenced by the current render. Animation remains best-effort progressive enhancement because email-client support is limited.
18
+ - Made `EHtml.lang` a required, non-empty value. `dir` still defaults to `ltr`.
19
+ - Made `ELink` visually neutral by removing its built-in color and underline. `target="_blank"` remains the default and can be overridden.
20
+ - Renamed `EmailRenderError.componentName` to `templateName` and kept the original error as `cause`.
21
+ - Limited module-driven registry rebuilds to structural template and fixture changes; ordinary edits continue through Nitro's normal watcher.
22
+
23
+ ### Breaking changes from v0.1.1
24
+
25
+ - Every `EHtml` now requires an explicit non-empty `lang`.
26
+ - `ELink` no longer supplies color or text-decoration styles. Add those styles explicitly where required.
27
+ - Read `EmailRenderError.templateName` instead of `componentName`; there is no compatibility alias.
28
+ - `defineEmail` rejects an empty object and invalid or asynchronous metadata values. Provide at least one synchronous `subject` or `text` value.
29
+
30
+ ## v0.1.1
31
+
32
+ [Compare changes](https://github.com/lupinum-dev/nuxt-email/compare/e45025a...v0.1.1)
33
+
34
+ ### Changed
35
+
36
+ - Isolated the package-preview watcher test from the normal unit-test process. This change prevents an open watcher from delaying test completion. ([#9](https://github.com/lupinum-dev/nuxt-email/pull/9))
37
+
38
+ ### Release reliability
39
+
40
+ - Added exact-byte recovery for a first package version that was published from a certified artifact before npm trusted publishing was available. The workflow rejects different package bytes and requires explicit bootstrap authorization. ([#8](https://github.com/lupinum-dev/nuxt-email/pull/8), [#10](https://github.com/lupinum-dev/nuxt-email/pull/10), [#11](https://github.com/lupinum-dev/nuxt-email/pull/11))
41
+ - Made package previews reliable after a cold start on Windows. ([#12](https://github.com/lupinum-dev/nuxt-email/pull/12))
42
+ - Corrected missing-tag handling and artifact checksum verification in the protected release workflow. ([#13](https://github.com/lupinum-dev/nuxt-email/pull/13), [#14](https://github.com/lupinum-dev/nuxt-email/pull/14))
43
+
44
+ ### Compatibility
45
+
46
+ - The runtime files are unchanged from `0.1.0`. This patch aligns the published package metadata with the current certified source and establishes the normal trusted-publishing path for later releases.
47
+
3
48
  ## v0.1.0
4
49
 
5
50
  Status: pre-1.0 candidate. The package identity is `@lupinum/nuxt-email`; the unscoped npm package is unrelated. Manual email-client QA and external beta evidence remain release gates.
package/README.md CHANGED
@@ -1,19 +1,31 @@
1
1
  <p align="center">
2
- <img src="docs/public/favicon.svg" width="128" alt="Nuxt Email icon">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/icon-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/icon-light.svg">
5
+ <img src="docs/public/icon-light.svg" width="128" alt="Nuxt Email icon">
6
+ </picture>
3
7
  </p>
4
8
 
5
- <h1 align="center">Nuxt Email</h1>
9
+ <h1 align="center">
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/wordmark-dark.svg">
12
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/wordmark-light.svg">
13
+ <img src="docs/public/wordmark-light.svg" width="256" alt="Nuxt Email">
14
+ </picture>
15
+ </h1>
6
16
 
7
17
  <p align="center">Write typed transactional email as Vue components and render it directly in Nuxt.</p>
8
18
 
9
19
  <p align="center">
10
- <a href="https://www.npmjs.com/package/@lupinum/nuxt-email"><img src="https://img.shields.io/npm/v/@lupinum/nuxt-email?label=npm" alt="npm version"></a>
20
+ <a href="https://www.npmjs.com/package/@lupinum/nuxt-email"><img src="https://img.shields.io/npm/v/@lupinum/nuxt-email?color=00DC82" alt="npm version"></a>
11
21
  <a href="https://github.com/lupinum-dev/nuxt-email/actions/workflows/ci.yml"><img src="https://github.com/lupinum-dev/nuxt-email/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
12
- <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
22
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-00DC82" alt="MIT license"></a>
23
+ <a href="https://discord.gg/RPH6SeA36N"><img src="https://img.shields.io/badge/Discord-18181B?logo=discord" alt="Discord"></a>
24
+ <a href="https://deepwiki.com/lupinum-dev/nuxt-email"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
13
25
  </p>
14
26
 
15
27
  > [!WARNING]
16
- > The first scoped release is in preparation. Public APIs can change before version 1.0. The unscoped `nuxt-email` package on npm is unrelated to this project.
28
+ > Nuxt Email is in the `1.0.0-beta.1` contract freeze. Review the changelog before each upgrade. Manual inbox QA and the external beta are still pending. The unscoped `nuxt-email` package on npm is unrelated to this project.
17
29
 
18
30
  ## Why use Nuxt Email?
19
31
 
@@ -90,6 +102,21 @@ export default defineEventHandler(async () => {
90
102
 
91
103
  The template name and props are generated from the Vue file. Invalid names and props fail during type checking.
92
104
 
105
+ ## Discord
106
+
107
+ Join the Lupinum OSS community to discuss Nuxt Email, ask questions, and share
108
+ what you build.
109
+
110
+ <p align="center">
111
+ <a href="https://discord.gg/RPH6SeA36N">
112
+ <picture>
113
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/discord-dark.svg">
114
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/discord-light.svg">
115
+ <img src="docs/public/discord-light.svg" width="500" alt="Join the Lupinum OSS Discord">
116
+ </picture>
117
+ </a>
118
+ </p>
119
+
93
120
  ## Email components
94
121
 
95
122
  Nuxt Email registers 18 server components for document structure, table layout, text, images, links, Outlook-safe buttons, Markdown, inline code, fonts, previews, and Tailwind.
@@ -114,6 +141,16 @@ Run the Nuxt application and open `/__email`. The development-only page shows th
114
141
 
115
142
  Add one sibling fixture file for deterministic sample props. Preview routes and fixtures are excluded from production builds.
116
143
 
144
+ ```ts
145
+ import type { EmailComponentProps } from '@lupinum/nuxt-email'
146
+ import type WelcomeEmail from './welcome.vue'
147
+
148
+ export default {
149
+ firstName: 'Ada',
150
+ activationUrl: 'https://example.com/activate',
151
+ } satisfies EmailComponentProps<typeof WelcomeEmail>
152
+ ```
153
+
117
154
  ## Testing
118
155
 
119
156
  Render a Vue email without starting Nuxt:
@@ -138,14 +175,14 @@ Complete the [manual client QA checklist](https://github.com/lupinum-dev/nuxt-em
138
175
 
139
176
  ## Package exports
140
177
 
141
- - `@lupinum/nuxt-email` provides the Nuxt module and the `RenderedEmail` type.
178
+ - `@lupinum/nuxt-email` provides the Nuxt module plus the `RenderedEmail` and `EmailComponentProps` types.
142
179
  - `@lupinum/nuxt-email/define-email` provides typed template metadata.
143
180
  - `@lupinum/nuxt-email/testing` provides standalone component rendering.
144
181
  - `@lupinum/nuxt-email/errors` provides supported runtime error classes.
145
182
 
146
183
  ## Documentation
147
184
 
148
- Read the [Nuxt Email documentation](https://nuxt-email.lupinum.com/docs). It contains the [installation guide](https://nuxt-email.lupinum.com/docs/getting-started/installation), [component reference](https://nuxt-email.lupinum.com/docs/components), [testing guide](https://nuxt-email.lupinum.com/docs/guides/testing-your-emails), and [preview guide](https://nuxt-email.lupinum.com/docs/guides/preview-workflow).
185
+ Read the [Nuxt Email documentation](https://nuxt-email.lupinum.com/docs). It contains the [installation guide](https://nuxt-email.lupinum.com/docs/getting-started/installation), [component reference](https://nuxt-email.lupinum.com/docs/components), [preview guide](https://nuxt-email.lupinum.com/docs/guides/preview-workflow), [testing guide](https://nuxt-email.lupinum.com/docs/guides/testing-your-emails), and [sending guide](https://nuxt-email.lupinum.com/docs/guides/sending-email).
149
186
 
150
187
  See the [changelog](CHANGELOG.md) for released changes.
151
188
 
package/dist/module.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
- export { RenderedEmail } from '../dist/runtime/render/types.js';
2
+ export { EmailComponentProps, RenderedEmail } from '../dist/runtime/render/types.js';
3
3
 
4
4
  interface CodeBlockOptions {
5
5
  languages: readonly string[];
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.5.1 <5"
6
6
  },
7
- "version": "0.1.0",
7
+ "version": "1.0.0-beta.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -140,14 +140,9 @@ ${entries.join("\n")}
140
140
  }>
141
141
 
142
142
  export type EmailTemplateName = keyof typeof emailTemplates
143
- type _EmailComponent = abstract new (...args: never[]) => { $props: object }
144
- type _DeclaredEmailProps<Component extends _EmailComponent> = Omit<
145
- InstanceType<Component>['$props'],
146
- keyof import('vue').PublicProps
147
- >
148
- type _EmailProps<Component extends _EmailComponent> = keyof _DeclaredEmailProps<Component> extends never
143
+ type _EmailProps<Component> = keyof import(${importPath(typePaths.renderedEmail)}).EmailComponentProps<Component> extends never
149
144
  ? Record<string, never>
150
- : _DeclaredEmailProps<Component>
145
+ : import(${importPath(typePaths.renderedEmail)}).EmailComponentProps<Component>
151
146
 
152
147
  export type EmailTemplateProps = {
153
148
  [Name in EmailTemplateName]: _EmailProps<Awaited<ReturnType<(typeof emailTemplates)[Name]['component']>>['default']>
@@ -160,6 +155,34 @@ export function renderEmail<Name extends EmailTemplateName>(
160
155
  `;
161
156
  }
162
157
 
158
+ function configureEmailTypeProject(tsConfig, emailDirectory, registryId, registryPath) {
159
+ const normalizedDirectory = emailDirectory.replaceAll("\\", "/");
160
+ const include = tsConfig.include ??= [];
161
+ const emailTypeGlob = `${normalizedDirectory}/**/*.vue`;
162
+ if (!include.includes(emailTypeGlob)) {
163
+ include.push(emailTypeGlob);
164
+ }
165
+ const exclude = tsConfig.exclude ??= [];
166
+ const emailComponentTypeGlob = `${normalizedDirectory}/components/**/*.vue`;
167
+ if (!exclude.includes(emailComponentTypeGlob)) {
168
+ exclude.push(emailComponentTypeGlob);
169
+ }
170
+ const paths = (tsConfig.compilerOptions ??= {}).paths ??= {};
171
+ paths[registryId] = [registryPath];
172
+ return paths;
173
+ }
174
+ function previewURL(baseURL) {
175
+ return `${baseURL.endsWith("/") ? baseURL : `${baseURL}/`}__email`;
176
+ }
177
+ function relativeEmailPath(emailDirectory, path, roots) {
178
+ const candidates = isAbsolute(path) ? [path] : roots.map((root) => resolve(root, path));
179
+ for (const candidate of candidates) {
180
+ const relativePath = relative(emailDirectory, candidate).replaceAll("\\", "/");
181
+ if (relativePath !== ".." && !relativePath.startsWith("../") && !isAbsolute(relativePath)) {
182
+ return relativePath;
183
+ }
184
+ }
185
+ }
163
186
  const module$1 = defineNuxtModule({
164
187
  meta: {
165
188
  name: "@lupinum/nuxt-email",
@@ -205,6 +228,9 @@ const module$1 = defineNuxtModule({
205
228
  write: true,
206
229
  getContents: () => generateEmailTypes(templates, registryTypePaths)
207
230
  }, { nitro: true });
231
+ nuxt.hook("prepare:types", ({ tsConfig: tsConfig2 }) => {
232
+ configureEmailTypeProject(tsConfig2, emailDirectory, registryId, typeTemplate.dst);
233
+ });
208
234
  addServerImports([
209
235
  {
210
236
  name: "renderEmail",
@@ -220,6 +246,17 @@ const module$1 = defineNuxtModule({
220
246
  });
221
247
  }
222
248
  if (nuxt.options.dev) {
249
+ nuxt.hook("devtools:customTabs", (tabs) => {
250
+ tabs.push({
251
+ name: "nuxt-email",
252
+ title: "Nuxt Email",
253
+ icon: "i-lucide-mail",
254
+ view: {
255
+ type: "iframe",
256
+ src: previewURL(nuxt.options.app.baseURL)
257
+ }
258
+ });
259
+ });
223
260
  addServerHandler({
224
261
  route: "/__email",
225
262
  method: "get",
@@ -250,21 +287,21 @@ const module$1 = defineNuxtModule({
250
287
  let registryUpdates = Promise.resolve();
251
288
  nuxt.hook("builder:watch", (event, path) => {
252
289
  const structureChanged = ["add", "unlink", "addDir", "unlinkDir"].includes(event);
253
- if (event !== "change" && !structureChanged) {
290
+ if (!structureChanged) {
254
291
  return;
255
292
  }
256
- const absolutePath = isAbsolute(path) ? path : resolve(nuxt.options.srcDir, path);
257
- const relativePath = relative(emailDirectory, absolutePath).replaceAll("\\", "/");
258
- if (relativePath === ".." || relativePath.startsWith("../") || isAbsolute(relativePath)) {
293
+ const relativePath = relativeEmailPath(emailDirectory, path, [
294
+ nuxt.options.rootDir,
295
+ nuxt.options.srcDir
296
+ ]);
297
+ if (relativePath === void 0) {
259
298
  return;
260
299
  }
261
300
  const update = async () => {
262
- if (structureChanged) {
263
- templates = await loadTemplates();
264
- await updateTemplates({
265
- filter: (template) => template.filename === typeTemplate.filename
266
- });
267
- }
301
+ templates = await loadTemplates();
302
+ await updateTemplates({
303
+ filter: (template) => template.filename === typeTemplate.filename
304
+ });
268
305
  await reloadRegistry();
269
306
  };
270
307
  const queuedUpdate = registryUpdates.then(update, update);
@@ -287,9 +324,7 @@ const module$1 = defineNuxtModule({
287
324
  }
288
325
  const typescript = nitro.typescript ??= {};
289
326
  const tsConfig = typescript.tsConfig ??= {};
290
- const compilerOptions = tsConfig.compilerOptions ??= {};
291
- const paths = compilerOptions.paths ??= {};
292
- paths[registryId] = [typeTemplate.dst];
327
+ const paths = configureEmailTypeProject(tsConfig, emailDirectory, registryId, typeTemplate.dst);
293
328
  paths["#nuxt-email/testing"] = [renderEmailComponentPath];
294
329
  nitro.rollupConfig ??= {};
295
330
  const existingPlugins = nitro.rollupConfig.plugins;
@@ -1,4 +1,6 @@
1
1
  import type { DefineComponent, HTMLAttributes } from 'vue';
2
2
  import type { SafeEmailAttributes } from './attributes.js';
3
- export type EHtmlProps = SafeEmailAttributes<HTMLAttributes>;
3
+ export type EHtmlProps = Omit<SafeEmailAttributes<HTMLAttributes>, 'lang'> & {
4
+ lang: string;
5
+ };
4
6
  export declare const EHtml: DefineComponent<EHtmlProps>;
@@ -3,10 +3,20 @@ import { assertSafeEmailAttributes } from "./attributes.js";
3
3
  export const EHtml = defineComponent({
4
4
  name: "EHtml",
5
5
  inheritAttrs: false,
6
- setup(_props, { attrs, slots }) {
6
+ props: {
7
+ lang: {
8
+ type: String,
9
+ required: true
10
+ }
11
+ },
12
+ setup(props, { attrs, slots }) {
7
13
  return () => {
8
14
  assertSafeEmailAttributes("EHtml", attrs);
9
- const { dir = "ltr", lang = "en", ...attributes } = attrs;
15
+ const { dir = "ltr", ...attributes } = attrs;
16
+ const { lang } = props;
17
+ if (typeof lang !== "string" || lang.trim().length === 0) {
18
+ throw new TypeError("EHtml lang must be a non-empty string");
19
+ }
10
20
  return h("html", {
11
21
  ...attributes,
12
22
  dir,
@@ -1,11 +1,6 @@
1
1
  import { defineComponent, h, inject } from "vue";
2
2
  import { resolveNestedTailwindStyle, TAILWIND_NESTED_KEY } from "../tailwind/nested.js";
3
3
  import { assertSafeEmailAttributes } from "./attributes.js";
4
- import { mergeEmailStyles } from "./style.js";
5
- const DEFAULT_LINK_STYLE = {
6
- color: "#067df7",
7
- textDecorationLine: "none"
8
- };
9
4
  export const ELink = defineComponent({
10
5
  name: "ELink",
11
6
  inheritAttrs: false,
@@ -27,7 +22,7 @@ export const ELink = defineComponent({
27
22
  return h("a", {
28
23
  ...attributes,
29
24
  href: props.href,
30
- style: mergeEmailStyles(DEFAULT_LINK_STYLE, effectiveStyle),
25
+ ...effectiveStyle === void 0 || effectiveStyle === null ? {} : { style: effectiveStyle },
31
26
  target: attrs.target ?? "_blank"
32
27
  }, slots.default?.());
33
28
  };
@@ -1,3 +1,28 @@
1
1
  export declare const PREVIEW_RENDER_CSP: string;
2
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<any>>;
2
+ interface SerializedPreviewError {
3
+ name: string;
4
+ message: string;
5
+ stack?: string;
6
+ templateName?: string;
7
+ requestedName?: string;
8
+ knownNames?: readonly string[];
9
+ cause?: SerializedPreviewError;
10
+ }
11
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<string | {
12
+ bytes: any;
13
+ html: string;
14
+ text: string;
15
+ subject?: string;
16
+ name: string;
17
+ statusCode?: undefined;
18
+ statusMessage?: undefined;
19
+ data?: undefined;
20
+ } | {
21
+ statusCode: number;
22
+ statusMessage: string;
23
+ data: {
24
+ templateName: string | undefined;
25
+ error: SerializedPreviewError;
26
+ };
27
+ }>>;
3
28
  export default _default;
@@ -45,8 +45,8 @@ function serializeError(error, seen = /* @__PURE__ */ new Set()) {
45
45
  if (error.stack) {
46
46
  serialized.stack = error.stack;
47
47
  }
48
- if ("componentName" in error && typeof error.componentName === "string") {
49
- serialized.componentName = error.componentName;
48
+ if ("templateName" in error && typeof error.templateName === "string") {
49
+ serialized.templateName = error.templateName;
50
50
  }
51
51
  if ("requestedName" in error && typeof error.requestedName === "string") {
52
52
  serialized.requestedName = error.requestedName;
@@ -2,12 +2,18 @@ import type { TailwindRegion } from '../tailwind/nested.js';
2
2
  /**
3
3
  * Options declared by an email template via {@link defineEmail}.
4
4
  *
5
- * `subject` is a zero-argument closure over the template's real `defineProps()`
6
- * value and returns the computed subject line.
5
+ * String values are constant metadata. Functions are zero-argument closures
6
+ * over the template's real `defineProps()` value and run synchronously after
7
+ * the HTML render completes.
7
8
  */
8
- export interface DefineEmailOptions {
9
- subject: () => string;
10
- }
9
+ export type EmailMetadataValue = string | (() => string);
10
+ export type DefineEmailOptions = {
11
+ subject: EmailMetadataValue;
12
+ text?: EmailMetadataValue;
13
+ } | {
14
+ subject?: EmailMetadataValue;
15
+ text: EmailMetadataValue;
16
+ };
11
17
  declare const EMAIL_RENDER_BRAND: unique symbol;
12
18
  /**
13
19
  * Per-render registry created by the renderer. A fresh instance is created for
@@ -16,7 +22,10 @@ declare const EMAIL_RENDER_BRAND: unique symbol;
16
22
  */
17
23
  export interface EmailRenderContext {
18
24
  readonly [EMAIL_RENDER_BRAND]: true;
19
- subject?: () => string;
25
+ metadata?: {
26
+ subject?: () => string;
27
+ text?: () => string;
28
+ };
20
29
  /**
21
30
  * Tailwind regions registered by `<ETailwind>` boundaries during render, in
22
31
  * registration order. Consumed by the post-render pass to complete each head
@@ -44,10 +53,10 @@ export declare function getEmailRenderContext(): EmailRenderContext | undefined;
44
53
  */
45
54
  export declare function runWithEmailRenderContext<T>(context: EmailRenderContext, fn: () => T): T;
46
55
  /**
47
- * Declare email metadata (currently the subject) from an email template's
48
- * `<script setup>`. The zero-argument subject closure captures the template's
49
- * real `defineProps()` value, so there is no separate generic that can lie about
50
- * the template contract. The computed value surfaces as `subject`.
56
+ * Declare subject and/or authored plain text from an email template's
57
+ * `<script setup>`. A zero-argument closure captures the template's real
58
+ * `defineProps()` value, so there is no separate generic that can lie about the
59
+ * template contract.
51
60
  *
52
61
  * Must run during an email render; calling it elsewhere throws
53
62
  * {@link DefineEmailOutsideRenderError}.
@@ -30,11 +30,28 @@ export function defineEmail(options) {
30
30
  if (!isEmailRenderContext(context)) {
31
31
  throw new DefineEmailOutsideRenderError();
32
32
  }
33
- if (context.subject !== void 0) {
33
+ if (context.metadata) {
34
34
  throw new DuplicateEmailDefinitionError();
35
35
  }
36
- if (!options || typeof options.subject !== "function") {
37
- throw new TypeError("defineEmail() subject must be a function returning a string.");
36
+ if (!options || typeof options !== "object") {
37
+ throw new TypeError("defineEmail() requires subject or text metadata.");
38
38
  }
39
- context.subject = options.subject;
39
+ const keys = Object.keys(options);
40
+ const unsupportedKeys = keys.filter((key) => key !== "subject" && key !== "text");
41
+ if (unsupportedKeys.length > 0) {
42
+ throw new TypeError(`defineEmail() received unsupported metadata: ${unsupportedKeys.sort().join(", ")}.`);
43
+ }
44
+ if (options.subject === void 0 && options.text === void 0) {
45
+ throw new TypeError("defineEmail() requires subject or text metadata.");
46
+ }
47
+ const toFactory = (name, value) => {
48
+ if (value === void 0) return void 0;
49
+ if (typeof value === "string") return () => value;
50
+ if (typeof value === "function") return value;
51
+ throw new TypeError(`defineEmail() ${name} must be a string or a function returning a string.`);
52
+ };
53
+ context.metadata = {
54
+ subject: toFactory("subject", options.subject),
55
+ text: toFactory("text", options.text)
56
+ };
40
57
  }
@@ -1,4 +1,4 @@
1
1
  export declare class EmailRenderError extends Error {
2
- readonly componentName: string;
3
- constructor(componentName: string, cause: unknown);
2
+ readonly templateName: string;
3
+ constructor(templateName: string, cause: unknown);
4
4
  }
@@ -1,8 +1,8 @@
1
1
  export class EmailRenderError extends Error {
2
- componentName;
3
- constructor(componentName, cause) {
4
- super(`Failed to render email component ${componentName}`, { cause });
2
+ templateName;
3
+ constructor(templateName, cause) {
4
+ super(`Failed to render email template ${templateName}`, { cause });
5
5
  this.name = "EmailRenderError";
6
- this.componentName = componentName;
6
+ this.templateName = templateName;
7
7
  }
8
8
  }
@@ -1,13 +1,10 @@
1
- import type { Component, FunctionalComponent, PublicProps } from 'vue';
1
+ import type { Component } from 'vue';
2
2
  import type { EmailComponentRegistry } from './render-component.js';
3
- import type { RenderedEmail } from './types.js';
4
- type DeclaredComponentProps<ComponentType extends Component> = ComponentType extends abstract new (...args: infer _Arguments) => infer Instance ? Instance extends {
5
- $props: infer Props;
6
- } ? Omit<Props, keyof PublicProps> : Record<string, unknown> : ComponentType extends FunctionalComponent<infer Props> ? Props : Record<string, unknown>;
3
+ import type { EmailComponentProps, RenderedEmail } from './types.js';
7
4
  type RequiredPropKeys<Props> = {
8
5
  [Key in keyof Props]-?: Record<never, never> extends Pick<Props, Key> ? never : Key;
9
6
  }[keyof Props];
10
- type RenderEmailComponentArguments<ComponentType extends Component> = keyof DeclaredComponentProps<ComponentType> extends never ? [props?: Record<string, never>] : RequiredPropKeys<DeclaredComponentProps<ComponentType>> extends never ? [props?: DeclaredComponentProps<ComponentType>] : [props: DeclaredComponentProps<ComponentType>];
7
+ type RenderEmailComponentArguments<ComponentType extends Component> = keyof EmailComponentProps<ComponentType> extends never ? [props?: Record<string, never>] : RequiredPropKeys<EmailComponentProps<ComponentType>> extends never ? [props?: EmailComponentProps<ComponentType>] : [props: EmailComponentProps<ComponentType>];
11
8
  export interface RenderEmailComponent {
12
9
  <ComponentType extends Component>(component: ComponentType, ...args: RenderEmailComponentArguments<ComponentType>): Promise<RenderedEmail>;
13
10
  }
@@ -4,7 +4,7 @@ import { assertCompleteEmailDocument } from "./document.js";
4
4
  import { EmailRenderError } from "./errors.js";
5
5
  import { renderPlainText } from "./plain-text.js";
6
6
  import { renderComponentToHtml } from "./render-component.js";
7
- function componentName(component) {
7
+ function templateName(component) {
8
8
  if (typeof component === "object" && component !== null || typeof component === "function") {
9
9
  if ("name" in component && typeof component.name === "string" && component.name.length > 0) {
10
10
  return component.name;
@@ -22,24 +22,24 @@ export function createRenderEmailComponent(componentRegistry) {
22
22
  const context = createEmailRenderContext();
23
23
  const html = await renderComponentToHtml(component, props, context, componentRegistry);
24
24
  assertCompleteEmailDocument(html);
25
- const subjectFactory = context.subject;
26
- if (subjectFactory === void 0) {
27
- return {
28
- html,
29
- text: renderPlainText(html)
30
- };
31
- }
32
- const subject = subjectFactory();
33
- if (typeof subject !== "string") {
34
- throw new TypeError(`defineEmail() subject must return a string; received ${typeof subject}`);
35
- }
36
- return {
25
+ const resolveMetadata = (name, factory) => {
26
+ if (!factory) return void 0;
27
+ const value = factory();
28
+ if (typeof value !== "string") {
29
+ throw new TypeError(`defineEmail() ${name} must return a string; received ${typeof value}`);
30
+ }
31
+ return value;
32
+ };
33
+ const subject = resolveMetadata("subject", context.metadata?.subject);
34
+ const text = resolveMetadata("text", context.metadata?.text) ?? renderPlainText(html);
35
+ const result = {
37
36
  html,
38
- text: renderPlainText(html),
39
- subject
37
+ text
40
38
  };
39
+ if (subject !== void 0) result.subject = subject;
40
+ return result;
41
41
  } catch (error) {
42
- throw new EmailRenderError(componentName(component), error);
42
+ throw new EmailRenderError(templateName(component), error);
43
43
  }
44
44
  };
45
45
  }
@@ -1,3 +1,9 @@
1
+ import type { FunctionalComponent, PublicProps } from 'vue';
2
+ type VueListenerProp = `on${Capitalize<string>}`;
3
+ type EmailAuthoringProps<Props> = string extends keyof Props ? Props : Omit<Props, keyof PublicProps | VueListenerProp>;
4
+ export type EmailComponentProps<ComponentType> = ComponentType extends abstract new (...args: never[]) => {
5
+ $props: infer Props;
6
+ } ? EmailAuthoringProps<Props> : ComponentType extends FunctionalComponent<infer Props> ? EmailAuthoringProps<Props> : never;
1
7
  export interface RenderedEmail {
2
8
  html: string;
3
9
  text: string;
@@ -7,3 +13,4 @@ export interface RenderedEmail {
7
13
  */
8
14
  subject?: string;
9
15
  }
16
+ export {};
@@ -1,41 +1,12 @@
1
1
  /**
2
- * Downlevels modern CSS features that email clients don't support,
3
- * operating on a css-tree StyleSheet AST.
4
- *
5
- * 1. CSS Nesting: unnests @media rules from inside selectors
6
- * `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
7
- * → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
8
- *
9
- * 2. Media Queries Level 4 range syntax → legacy min-width/max-width
10
- * `(width>=40rem)` → `(min-width:40rem)`
11
- *
12
- * Gmail, Outlook, Yahoo, and most email clients don't support either feature.
13
- * See: https://www.caniemail.com/features/css-at-media/
14
- * https://www.caniemail.com/features/css-nesting/
2
+ * Downlevel the modern CSS emitted by Tailwind to shapes that email clients
3
+ * can parse. The transform is deliberately strict: leaving one nested rule in
4
+ * the output is worse than failing the render with an actionable error.
15
5
  */
16
6
  import { type StyleSheet } from 'css-tree';
17
7
  /**
18
- * css-tree 3.x introduced new AST node types for query-related at-rules that
19
- * `@types/css-tree` (still on 2.x at the time of writing) doesn't expose.
20
- * Augmenting the module here lets the rest of this file work with strong
21
- * types instead of scattering `as` casts everywhere.
22
- *
23
- * - `FeatureRange` is what the parser emits for Media Queries Level 4 range
24
- * syntax: `(width >= 40rem)`.
25
- * - `Feature` is the legacy form (`(min-width: 40rem)`) we construct as the
26
- * downleveled output.
27
- *
28
- * Note: we cannot extend the `CssNode` union itself (it's a `type` alias, not
29
- * an interface), so two narrow casts remain in this file:
30
- * 1. Widening `node` inside `walk()` so we can narrow against
31
- * `FeatureRange.type` (`CssNode` doesn't list `'FeatureRange'`).
32
- * 2. Assigning a constructed `Feature` back to `ListItem<CssNode>.data`.
33
- *
34
- * Both are flagged inline and reference back to this block.
35
- *
36
- * See:
37
- * https://github.com/csstree/csstree/blob/master/lib/syntax/node/FeatureRange.js
38
- * https://github.com/csstree/csstree/blob/master/lib/syntax/node/Feature.js
8
+ * css-tree 3.x parses media range syntax, but the currently published type
9
+ * package does not expose the corresponding nodes yet.
39
10
  */
40
11
  declare module 'css-tree' {
41
12
  interface FeatureRange extends CssNodeCommon {
@@ -55,9 +26,8 @@ declare module 'css-tree' {
55
26
  }
56
27
  }
57
28
  /**
58
- * Unnest @media at-rules from inside regular rules, and downlevel
59
- * range media query syntax to legacy min-width/max-width.
60
- *
29
+ * Recursively flatten selector nesting and nested @media/@supports rules,
30
+ * then convert media range syntax such as `(width >= 40rem)` to `min-width`.
61
31
  * Mutates the stylesheet in place.
62
32
  */
63
33
  export declare function downlevelForEmailClients(styleSheet: StyleSheet): void;
@@ -1,75 +1,171 @@
1
1
  import {
2
2
  clone,
3
+ generate,
3
4
  List,
5
+ parse,
4
6
  walk
5
7
  } from "css-tree";
6
8
  export function downlevelForEmailClients(styleSheet) {
7
- unnestMediaQueries(styleSheet);
9
+ styleSheet.children = flattenStyleSheet(styleSheet);
8
10
  downlevelRangeMediaQueries(styleSheet);
11
+ walk(styleSheet, (node) => {
12
+ if (node.type === "NestingSelector") {
13
+ throw new TypeError(
14
+ "Unable to render Tailwind CSS: an unsupported nesting selector remained after downleveling."
15
+ );
16
+ }
17
+ });
9
18
  }
10
- function unnestMediaQueries(styleSheet) {
11
- const transforms = [];
12
- walk(styleSheet, {
13
- visit: "Rule",
14
- enter(rule, item, list) {
15
- if (!rule.block || !item) return;
16
- const nestedAtrules = [];
17
- const remainingChildren = [];
18
- rule.block.children.forEach((child) => {
19
- if (child.type === "Atrule" && (child.name === "media" || child.name === "supports")) {
20
- nestedAtrules.push(child);
21
- } else {
22
- remainingChildren.push(child);
23
- }
24
- });
25
- if (nestedAtrules.length > 0) {
26
- transforms.push({
27
- parentRule: rule,
28
- parentItem: item,
29
- parentList: list,
30
- nestedAtrules,
31
- remainingChildren
32
- });
33
- }
19
+ function flattenStyleSheet(styleSheet) {
20
+ const output = new List();
21
+ styleSheet.children.forEach((node) => {
22
+ if (node.type === "Rule") {
23
+ flattenRule(node, null, [], output);
24
+ return;
25
+ }
26
+ if (node.type === "Atrule" && isConditionalAtrule(node)) {
27
+ flattenConditionalAtrule(node, null, [], output);
28
+ return;
34
29
  }
30
+ output.appendData(clone(node));
35
31
  });
36
- for (let i = transforms.length - 1; i >= 0; i--) {
37
- const {
38
- parentRule,
39
- parentItem,
40
- parentList,
41
- nestedAtrules,
42
- remainingChildren
43
- } = transforms[i];
44
- const replacements = new List();
45
- if (remainingChildren.length > 0) {
46
- parentRule.block.children = new List().fromArray(
47
- remainingChildren
32
+ return output;
33
+ }
34
+ function flattenRule(rule, parentSelectors, wrappers, output) {
35
+ const selectors = resolveSelectors(rule, parentSelectors);
36
+ flattenBlock(rule.block.children, selectors, wrappers, output);
37
+ }
38
+ function flattenBlock(children, selectors, wrappers, output) {
39
+ let declarations = new List();
40
+ const flushDeclarations = () => {
41
+ if (declarations.isEmpty) return;
42
+ if (!selectors) {
43
+ throw new TypeError(
44
+ "Unable to render Tailwind CSS: declarations were found outside a selector."
48
45
  );
49
- replacements.appendData(parentRule);
50
46
  }
51
- for (const atrule of nestedAtrules) {
52
- const wrappedRule = {
53
- type: "Rule",
54
- prelude: clone(parentRule.prelude),
55
- block: {
56
- type: "Block",
57
- children: atrule.block ? atrule.block.children : new List()
58
- }
59
- };
60
- const newAtrule = {
61
- type: "Atrule",
62
- name: atrule.name,
63
- prelude: atrule.prelude,
64
- block: {
65
- type: "Block",
66
- children: new List().fromArray([wrappedRule])
67
- }
68
- };
69
- replacements.appendData(newAtrule);
47
+ appendWrappedRule(selectors, declarations, wrappers, output);
48
+ declarations = new List();
49
+ };
50
+ children.forEach((node) => {
51
+ if (node.type === "Raw") {
52
+ throw new TypeError(
53
+ `Unable to render Tailwind CSS: an unparsed nested CSS shape was encountered (${node.value}).`
54
+ );
55
+ }
56
+ if (node.type === "Rule") {
57
+ flushDeclarations();
58
+ flattenRule(node, selectors, wrappers, output);
59
+ return;
60
+ }
61
+ if (node.type === "Atrule") {
62
+ flushDeclarations();
63
+ if (!isConditionalAtrule(node)) {
64
+ throw new TypeError(
65
+ `Unable to render Tailwind CSS: nested @${node.name} rules are not supported.`
66
+ );
67
+ }
68
+ flattenConditionalAtrule(node, selectors, wrappers, output);
69
+ return;
70
70
  }
71
- parentList.replace(parentItem, replacements);
71
+ declarations.appendData(clone(node));
72
+ });
73
+ flushDeclarations();
74
+ }
75
+ function flattenConditionalAtrule(atrule, selectors, wrappers, output) {
76
+ if (!atrule.block) {
77
+ throw new TypeError(
78
+ `Unable to render Tailwind CSS: @${atrule.name} has no rule block.`
79
+ );
80
+ }
81
+ flattenBlock(
82
+ atrule.block.children,
83
+ selectors,
84
+ [
85
+ ...wrappers,
86
+ { name: atrule.name, prelude: clonePrelude(atrule.prelude) }
87
+ ],
88
+ output
89
+ );
90
+ }
91
+ function appendWrappedRule(selectors, declarations, wrappers, output) {
92
+ let node = {
93
+ type: "Rule",
94
+ prelude: clone(selectors),
95
+ block: { type: "Block", children: declarations }
96
+ };
97
+ for (let index = wrappers.length - 1; index >= 0; index--) {
98
+ const wrapper = wrappers[index];
99
+ node = {
100
+ type: "Atrule",
101
+ name: wrapper.name,
102
+ prelude: clonePrelude(wrapper.prelude),
103
+ block: {
104
+ type: "Block",
105
+ children: new List().fromArray([node])
106
+ }
107
+ };
108
+ }
109
+ output.appendData(node);
110
+ }
111
+ function resolveSelectors(rule, parentSelectors) {
112
+ if (rule.prelude.type !== "SelectorList") {
113
+ throw new TypeError(
114
+ "Unable to render Tailwind CSS: raw selectors cannot be downleveled safely."
115
+ );
116
+ }
117
+ if (!parentSelectors) {
118
+ assertNoNestingSelector(rule.prelude);
119
+ return clone(rule.prelude);
72
120
  }
121
+ const resolved = new List();
122
+ const nestedSelectors = rule.prelude;
123
+ parentSelectors.children.forEach((parentNode) => {
124
+ const parent = parentNode;
125
+ nestedSelectors.children.forEach((nestedNode) => {
126
+ const nested = nestedNode;
127
+ if (containsNestingSelector(nested)) {
128
+ const selector = clone(nested);
129
+ walk(selector, {
130
+ enter(node, item, list) {
131
+ if (node.type === "NestingSelector") {
132
+ list.replace(item, clone(parent).children);
133
+ }
134
+ }
135
+ });
136
+ resolved.appendData(selector);
137
+ } else {
138
+ const selector = parse(
139
+ `${generate(parent)} ${generate(nested)}`,
140
+ { context: "selector" }
141
+ );
142
+ resolved.appendData(selector);
143
+ }
144
+ });
145
+ });
146
+ return { type: "SelectorList", children: resolved };
147
+ }
148
+ function containsNestingSelector(selector) {
149
+ let found = false;
150
+ walk(selector, (node) => {
151
+ if (node.type === "NestingSelector") found = true;
152
+ });
153
+ return found;
154
+ }
155
+ function assertNoNestingSelector(selectors) {
156
+ walk(selectors, (node) => {
157
+ if (node.type === "NestingSelector") {
158
+ throw new TypeError(
159
+ "Unable to render Tailwind CSS: a top-level nesting selector has no parent selector."
160
+ );
161
+ }
162
+ });
163
+ }
164
+ function isConditionalAtrule(atrule) {
165
+ return atrule.name === "media" || atrule.name === "supports";
166
+ }
167
+ function clonePrelude(prelude) {
168
+ return prelude ? clone(prelude) : null;
73
169
  }
74
170
  function downlevelRangeMediaQueries(styleSheet) {
75
171
  const replacements = [];
@@ -77,10 +173,7 @@ function downlevelRangeMediaQueries(styleSheet) {
77
173
  enter(originalNode, item) {
78
174
  const node = originalNode;
79
175
  if (item && node.type === "FeatureRange") {
80
- const replacement = downlevelFeatureRange(node);
81
- if (replacement) {
82
- replacements.push({ item, replacement });
83
- }
176
+ replacements.push({ item, replacement: downlevelFeatureRange(node) });
84
177
  }
85
178
  }
86
179
  });
@@ -89,19 +182,40 @@ function downlevelRangeMediaQueries(styleSheet) {
89
182
  }
90
183
  }
91
184
  function downlevelFeatureRange(range) {
92
- if (range.left.type !== "Identifier") return null;
93
- let prefix;
94
- if (range.leftComparison === ">=" || range.leftComparison === ">") {
95
- prefix = "min-";
96
- } else if (range.leftComparison === "<=" || range.leftComparison === "<") {
97
- prefix = "max-";
185
+ const source = generate(range);
186
+ if (range.rightComparison !== null || range.right !== null) {
187
+ throw new TypeError(
188
+ `Unable to render Tailwind CSS: unsupported two-sided media range ${source}.`
189
+ );
190
+ }
191
+ let name;
192
+ let value;
193
+ let direct;
194
+ if (range.left.type === "Identifier") {
195
+ name = range.left.name;
196
+ value = range.middle;
197
+ direct = true;
198
+ } else if (range.middle.type === "Identifier") {
199
+ name = range.middle.name;
200
+ value = range.left;
201
+ direct = false;
98
202
  } else {
99
- return null;
203
+ throw new TypeError(
204
+ `Unable to render Tailwind CSS: unsupported media range ${source}.`
205
+ );
206
+ }
207
+ const comparison = range.leftComparison;
208
+ const minimum = direct ? comparison === ">=" || comparison === ">" : comparison === "<=" || comparison === "<";
209
+ const maximum = direct ? comparison === "<=" || comparison === "<" : comparison === ">=" || comparison === ">";
210
+ if (!minimum && !maximum) {
211
+ throw new TypeError(
212
+ `Unable to render Tailwind CSS: unsupported media range ${source}.`
213
+ );
100
214
  }
101
215
  return {
102
216
  type: "Feature",
103
217
  kind: "media",
104
- name: `${prefix}${range.left.name}`,
105
- value: range.middle
218
+ name: `${minimum ? "min-" : "max-"}${name}`,
219
+ value
106
220
  };
107
221
  }
@@ -2,5 +2,7 @@ import { type CssNode, type Rule } from 'css-tree';
2
2
  export interface ExtractedRules {
3
3
  inlinable: Map<string, Rule[]>;
4
4
  nonInlinable: Map<string, Rule[]>;
5
+ /** Canonical non-inlinable rules, once each and in stylesheet order. */
6
+ orderedNonInlinable: Rule[];
5
7
  }
6
8
  export declare function extractRulesPerClass(root: CssNode, classes: string[]): ExtractedRules;
@@ -5,6 +5,7 @@ export function extractRulesPerClass(root, classes) {
5
5
  const classSet = new Set(classes);
6
6
  const inlinableRules = /* @__PURE__ */ new Map();
7
7
  const nonInlinableRules = /* @__PURE__ */ new Map();
8
+ const orderedNonInlinableRules = [];
8
9
  const appendRule = (map, className, rule) => {
9
10
  const existing = map.get(className);
10
11
  if (existing) {
@@ -35,8 +36,10 @@ export function extractRulesPerClass(root, classes) {
35
36
  }
36
37
  } else {
37
38
  const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
39
+ let includesRequestedClass = false;
38
40
  for (const className of selectorClasses) {
39
41
  if (!classSet.has(className)) continue;
42
+ includesRequestedClass = true;
40
43
  if (inlinablePart) {
41
44
  appendRule(inlinableRules, className, inlinablePart);
42
45
  }
@@ -44,11 +47,15 @@ export function extractRulesPerClass(root, classes) {
44
47
  appendRule(nonInlinableRules, className, nonInlinablePart);
45
48
  }
46
49
  }
50
+ if (includesRequestedClass && nonInlinablePart) {
51
+ orderedNonInlinableRules.push(nonInlinablePart);
52
+ }
47
53
  }
48
54
  }
49
55
  });
50
56
  return {
51
57
  inlinable: inlinableRules,
52
- nonInlinable: nonInlinableRules
58
+ nonInlinable: nonInlinableRules,
59
+ orderedNonInlinable: orderedNonInlinableRules
53
60
  };
54
61
  }
@@ -30,12 +30,12 @@ export interface ComputedStyles {
30
30
  */
31
31
  residualClassMap: Map<string, string>;
32
32
  /**
33
- * Original (unsanitized) names of every class that produced a non-inlinable
34
- * rule, in stylesheet-emission order the exact list React Email interpolates
35
- * into its no-`<head>` error (`Array.from(nonInlinableRules.keys())`,
36
- * tailwind.tsx). This order (breakpoint/variant emission order) differs from the
37
- * authored class order, so it cannot be reconstructed from {@link residualClassMap}
38
- * and must be carried canonically.
33
+ * Original (unsanitized) names of classes that need the generated `<head>`
34
+ * CSS: non-inlinable rules and animation declarations with referenced
35
+ * keyframes. Non-inlinable rules keep stylesheet-emission order; animation-only
36
+ * classes follow in authored order. This list drives the missing-`<head>` error
37
+ * and cannot be reconstructed from {@link residualClassMap}, because an
38
+ * animation declaration itself can still be inlined.
39
39
  */
40
40
  nonInlinableClassNames: string[];
41
41
  }
@@ -1,4 +1,9 @@
1
- import { generate, List } from "css-tree";
1
+ import {
2
+ clone,
3
+ generate,
4
+ List,
5
+ walk
6
+ } from "css-tree";
2
7
  import { downlevelForEmailClients } from "./css/downlevel-for-email-clients.js";
3
8
  import { extractRulesPerClass } from "./css/extract-rules-per-class.js";
4
9
  import { getCustomProperties } from "./css/get-custom-properties.js";
@@ -7,6 +12,35 @@ import { sanitizeClassName } from "./compatibility/sanitize-class-name.js";
7
12
  import { sanitizeNonInlinableRules } from "./css/sanitize-non-inlinable-rules.js";
8
13
  import { sanitizeStyleSheet } from "./sanitize-stylesheet.js";
9
14
  import { setupTailwind } from "./setup-tailwind.js";
15
+ function collectKeyframesDefinitions(styleSheet) {
16
+ const definitions = [];
17
+ styleSheet.children.forEach((node) => {
18
+ if (node.type === "Atrule" && (node.name === "keyframes" || node.name === "-webkit-keyframes") && node.prelude) {
19
+ definitions.push({ name: generate(node.prelude).trim(), node });
20
+ }
21
+ });
22
+ return definitions;
23
+ }
24
+ function collectReferencedKeyframes(rulesByClass, knownNames) {
25
+ const references = /* @__PURE__ */ new Map();
26
+ for (const [className, rules] of rulesByClass) {
27
+ const classReferences = /* @__PURE__ */ new Set();
28
+ for (const rule of rules) {
29
+ walk(rule, {
30
+ visit: "Declaration",
31
+ enter(declaration) {
32
+ if (declaration.property !== "animation" && declaration.property !== "animation-name") return;
33
+ walk(declaration.value, (node) => {
34
+ const name = node.type === "Identifier" ? node.name : node.type === "String" ? node.value : null;
35
+ if (name && knownNames.has(name)) classReferences.add(name);
36
+ });
37
+ }
38
+ });
39
+ }
40
+ if (classReferences.size > 0) references.set(className, classReferences);
41
+ }
42
+ return references;
43
+ }
10
44
  export async function createTailwindEngine(options = {}) {
11
45
  const { config, theme, utility } = options;
12
46
  const tailwindSetup = await setupTailwind({
@@ -17,17 +51,42 @@ export async function createTailwindEngine(options = {}) {
17
51
  tailwindSetup.addUtilities(classNames);
18
52
  const styleSheet = tailwindSetup.getStyleSheet();
19
53
  sanitizeStyleSheet(styleSheet);
20
- const { inlinable: inlinableRules, nonInlinable: nonInlinableRules } = extractRulesPerClass(styleSheet, classNames);
54
+ const {
55
+ inlinable: inlinableRules,
56
+ nonInlinable: nonInlinableRules,
57
+ orderedNonInlinable
58
+ } = extractRulesPerClass(styleSheet, classNames);
21
59
  const customProperties = getCustomProperties(styleSheet);
60
+ const keyframesDefinitions = collectKeyframesDefinitions(styleSheet);
61
+ const knownKeyframeNames = new Set(
62
+ keyframesDefinitions.map((definition) => definition.name)
63
+ );
64
+ const animationReferences = /* @__PURE__ */ new Map();
65
+ for (const rules of [inlinableRules, nonInlinableRules]) {
66
+ for (const [className, names] of collectReferencedKeyframes(
67
+ rules,
68
+ knownKeyframeNames
69
+ )) {
70
+ const existing = animationReferences.get(className) ?? /* @__PURE__ */ new Set();
71
+ for (const name of names) existing.add(name);
72
+ animationReferences.set(className, existing);
73
+ }
74
+ }
75
+ const referencedKeyframeNames = new Set(
76
+ Array.from(animationReferences.values()).flatMap((names) => [...names])
77
+ );
22
78
  const nonInlineStyles = {
23
79
  type: "StyleSheet",
24
- children: new List().fromArray(
25
- Array.from(nonInlinableRules.values()).flat()
26
- )
80
+ children: new List().fromArray(orderedNonInlinable)
27
81
  };
28
82
  sanitizeNonInlinableRules(nonInlineStyles);
83
+ for (const definition of keyframesDefinitions) {
84
+ if (referencedKeyframeNames.has(definition.name)) {
85
+ nonInlineStyles.children.appendData(clone(definition.node));
86
+ }
87
+ }
29
88
  downlevelForEmailClients(nonInlineStyles);
30
- const nonInlinableCss = nonInlinableRules.size > 0 ? generate(nonInlineStyles) : "";
89
+ const nonInlinableCss = nonInlineStyles.children.isEmpty ? "" : generate(nonInlineStyles);
31
90
  const inlinable = /* @__PURE__ */ new Map();
32
91
  for (const [className, rules] of inlinableRules) {
33
92
  inlinable.set(className, makeInlineStylesFor(rules, customProperties));
@@ -41,11 +100,15 @@ export async function createTailwindEngine(options = {}) {
41
100
  residualClassMap.set(className, className);
42
101
  }
43
102
  }
103
+ const nonInlinableClassNames = new Set(nonInlinableRules.keys());
104
+ for (const className of classNames) {
105
+ if (animationReferences.has(className) && !nonInlinableRules.has(className)) nonInlinableClassNames.add(className);
106
+ }
44
107
  return {
45
108
  inlinable,
46
109
  nonInlinableCss,
47
110
  residualClassMap,
48
- nonInlinableClassNames: Array.from(nonInlinableRules.keys())
111
+ nonInlinableClassNames: [...nonInlinableClassNames]
49
112
  };
50
113
  };
51
114
  return { computeStyles };
@@ -63,12 +63,7 @@ function rewriteOpenTag(openTag, newClass, newStyle) {
63
63
  }
64
64
  return tag;
65
65
  }
66
- function inlinePlainElements(fragment, region) {
67
- const hits = collectElements(fragment);
68
- for (const hit of hits) {
69
- region.classNames.push(...classTokens(hit.classValue));
70
- }
71
- const computed = region.engine.computeStyles(region.classNames);
66
+ function inlinePlainElements(fragment, hits, computed) {
72
67
  const edits = [];
73
68
  for (const hit of hits) {
74
69
  const tokens = classTokens(hit.classValue);
@@ -131,8 +126,12 @@ function processRegion(html, region) {
131
126
  if (startIndex === -1 || endIndex === -1) return html;
132
127
  const fragmentStart = startIndex + startComment.length;
133
128
  const fragment = html.slice(fragmentStart, endIndex);
134
- const inlined = inlinePlainElements(fragment, region);
129
+ const hits = collectElements(fragment);
130
+ for (const hit of hits) {
131
+ region.classNames.push(...classTokens(hit.classValue));
132
+ }
135
133
  const computed = region.engine.computeStyles(region.classNames);
134
+ const inlined = inlinePlainElements(fragment, hits, computed);
136
135
  if (inlined.includes(region.placeholder)) {
137
136
  const completed = inlined.replace(region.placeholder, computed.nonInlinableCss);
138
137
  return html.slice(0, startIndex) + completed + html.slice(endIndex + endComment.length);
package/dist/types.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- export { type RenderedEmail } from '../dist/runtime/render/types.js'
1
+ export { type EmailComponentProps, type RenderedEmail } from '../dist/runtime/render/types.js'
2
2
 
3
3
  export { default } from './module.mjs'
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/nuxt-email",
3
- "version": "0.1.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "Typed transactional email for Nuxt with email-safe Tailwind v4",
5
5
  "keywords": [
6
6
  "email",
@@ -92,7 +92,8 @@
92
92
  "typescript": "~5.9.3",
93
93
  "vitest": "^4.1.8",
94
94
  "vue": "3.5.40",
95
- "vue-tsc": "^3.3.3"
95
+ "vue-tsc": "^3.3.3",
96
+ "yaml": "2.9.0"
96
97
  },
97
98
  "scripts": {
98
99
  "changelog": "changelogen --no-output",
@@ -105,7 +106,8 @@
105
106
  "oracle:check": "node --import tsx test/conformance/tooling/generate-react-oracle.ts --check",
106
107
  "oracle:write": "node --import tsx test/conformance/tooling/generate-react-oracle.ts --write",
107
108
  "performance:measure": "node --expose-gc --import tsx test/performance/measure.ts",
108
- "docs:build": "nuxt-module-build prepare && pnpm prepack && pnpm --dir docs build",
109
+ "docs:build": "pnpm docs:theme && nuxt-module-build prepare && pnpm prepack && pnpm --dir docs build",
110
+ "docs:theme": "node scripts/check-docs-theme.mjs",
109
111
  "proofs:generate": "node --import tsx scripts/proofs/generate.ts",
110
112
  "proofs:send": "node --import tsx scripts/proofs/send.ts",
111
113
  "repo:check": "node scripts/check-repository-policy.mjs",
@@ -113,7 +115,8 @@
113
115
  "release:prepare": "changelogen --bump --clean --hideAuthorEmail",
114
116
  "release:artifact": "pnpm release:verify --output .release/lupinum-nuxt-email.tgz && node scripts/write-release-manifest.mjs",
115
117
  "lint": "eslint .",
116
- "test": "vitest run",
118
+ "test": "vitest run --exclude test/preview.test.ts && pnpm test:preview",
119
+ "test:preview": "vitest run test/preview.test.ts",
117
120
  "test:conformance": "vitest run test/conformance",
118
121
  "test:watch": "vitest watch",
119
122
  "test:types": "pnpm dev:prepare && nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/code-block && nuxt prepare test/fixtures/preview && vue-tsc --noEmit && vue-tsc --noEmit -p test/fixtures/basic/.nuxt/tsconfig.server.json && vue-tsc --noEmit -p test/fixtures/code-block/.nuxt/tsconfig.server.json && vue-tsc --noEmit -p test/fixtures/preview/.nuxt/tsconfig.server.json && cd playground && vue-tsc --noEmit",