@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 +45 -0
- package/README.md +44 -7
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -20
- package/dist/runtime/components/EHtml.d.ts +3 -1
- package/dist/runtime/components/EHtml.js +12 -2
- package/dist/runtime/components/ELink.js +1 -6
- package/dist/runtime/dev-preview/render.get.d.ts +26 -1
- package/dist/runtime/dev-preview/render.get.js +2 -2
- package/dist/runtime/render/define-email.d.ts +19 -10
- package/dist/runtime/render/define-email.js +21 -4
- package/dist/runtime/render/errors.d.ts +2 -2
- package/dist/runtime/render/errors.js +4 -4
- package/dist/runtime/render/render-email-component.d.ts +3 -6
- package/dist/runtime/render/render-email-component.js +16 -16
- package/dist/runtime/render/types.d.ts +7 -0
- package/dist/runtime/tailwind/engine/css/downlevel-for-email-clients.d.ts +7 -37
- package/dist/runtime/tailwind/engine/css/downlevel-for-email-clients.js +185 -71
- package/dist/runtime/tailwind/engine/css/extract-rules-per-class.d.ts +2 -0
- package/dist/runtime/tailwind/engine/css/extract-rules-per-class.js +8 -1
- package/dist/runtime/tailwind/engine/index.d.ts +6 -6
- package/dist/runtime/tailwind/engine/index.js +70 -7
- package/dist/runtime/tailwind/post-render.js +6 -7
- package/dist/types.d.mts +1 -1
- package/package.json +7 -4
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
|
-
<
|
|
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">
|
|
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?
|
|
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-
|
|
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
|
-
>
|
|
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
|
|
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 [
|
|
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
package/dist/module.json
CHANGED
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
|
|
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
|
-
:
|
|
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 (
|
|
290
|
+
if (!structureChanged) {
|
|
254
291
|
return;
|
|
255
292
|
}
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
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:
|
|
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
|
-
|
|
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 ("
|
|
49
|
-
serialized.
|
|
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
|
-
*
|
|
6
|
-
* value and
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
* `<script setup>`.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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.
|
|
33
|
+
if (context.metadata) {
|
|
34
34
|
throw new DuplicateEmailDefinitionError();
|
|
35
35
|
}
|
|
36
|
-
if (!options || typeof options
|
|
37
|
-
throw new TypeError("defineEmail() subject
|
|
36
|
+
if (!options || typeof options !== "object") {
|
|
37
|
+
throw new TypeError("defineEmail() requires subject or text metadata.");
|
|
38
38
|
}
|
|
39
|
-
|
|
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,8 +1,8 @@
|
|
|
1
1
|
export class EmailRenderError extends Error {
|
|
2
|
-
|
|
3
|
-
constructor(
|
|
4
|
-
super(`Failed to render email
|
|
2
|
+
templateName;
|
|
3
|
+
constructor(templateName, cause) {
|
|
4
|
+
super(`Failed to render email template ${templateName}`, { cause });
|
|
5
5
|
this.name = "EmailRenderError";
|
|
6
|
-
this.
|
|
6
|
+
this.templateName = templateName;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import type { Component
|
|
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
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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: `${
|
|
105
|
-
value
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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:
|
|
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,
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lupinum/nuxt-email",
|
|
3
|
-
"version": "0.1
|
|
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",
|