@emailens/engine 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/dist/compile/index.d.cts +2 -2
- package/dist/compile/index.d.ts +2 -2
- package/dist/index.cjs +328 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -5
- package/dist/index.d.ts +74 -5
- package/dist/index.js +323 -19
- package/dist/index.js.map +1 -1
- package/dist/{react-email-D24HkW2Z.d.ts → react-email-BROBcC8u.d.ts} +1 -1
- package/dist/{react-email-BWIfwG7U.d.cts → react-email-DuZAgJtw.d.cts} +1 -1
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/{types-BFhPXh3c.d.cts → types-bvGDId6f.d.cts} +9 -1
- package/dist/{types-BFhPXh3c.d.ts → types-bvGDId6f.d.ts} +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://github.com/emailens/engine/actions/workflows/ci.yml)
|
|
11
11
|
[](https://www.npmjs.com/package/@emailens/engine)
|
|
12
12
|
[](./LICENSE)
|
|
13
|
-
[]()
|
|
14
14
|
[](https://nodejs.org/)
|
|
15
15
|
[](https://github.com/emailens/mcp)
|
|
16
16
|
[](https://github.com/emailens/engine/stargazers)
|
|
@@ -106,11 +106,14 @@ const { code } = await generateAiFix({
|
|
|
106
106
|
|
|
107
107
|
## What It Catches
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
14 analysis engines, one `auditEmail()` call.
|
|
110
110
|
|
|
111
111
|
- **CSS compatibility**: 255 CSS and HTML features tested across 21 email clients, with fix snippets and AI-powered auto-fix
|
|
112
|
+
- **Outlook VML**: structural faults in the Outlook-only markup inside `<!--[if mso]>` conditional comments — the one part of an email a DOM analyzer structurally cannot see, since to every HTML parser it is a comment node and to a screenshot it does not exist
|
|
112
113
|
- **Content overflow**: fixed widths wider than the email frame and unbreakable strings that force horizontal scrolling
|
|
113
114
|
- **Visual bugs**: gradients/background images with no color fallback (invisible content in Outlook) and fonts with no web-safe fallback, each with a concrete fix
|
|
115
|
+
- **Design consistency**: colours that differ as values but not to a reader (OKLab distance), and runaway cardinality in type sizes, families and corner radii
|
|
116
|
+
- **Dark and mobile contrast**: the two renders nobody checks — what a client's forced inversion does, what the email's own `prefers-color-scheme` block does, and what changes below the mobile breakpoint
|
|
114
117
|
- **Spam scoring**: 45+ signals modeled after SpamAssassin, CAN-SPAM, and GDPR
|
|
115
118
|
- **Accessibility**: WCAG contrast ratios, alt text, semantic structure, heading hierarchy
|
|
116
119
|
- **Link validation**: broken hrefs, insecure HTTP, `javascript:` protocols, deceptive URLs
|
|
@@ -207,7 +210,7 @@ Full API reference: **[docs/API.md](./docs/API.md)**
|
|
|
207
210
|
|
|
208
211
|
Covers:
|
|
209
212
|
- `auditEmail` and `createSession`: core analysis
|
|
210
|
-
- Standalone analyzers (CSS, spam, links, accessibility, images, inbox preview, size, templates)
|
|
213
|
+
- Standalone analyzers (CSS, VML, spam, links, accessibility, images, inbox preview, size, templates)
|
|
211
214
|
- DNS deliverability and SpamAssassin integration
|
|
212
215
|
- Client transforms and dark mode simulation
|
|
213
216
|
- Compile module (JSX, MJML, Maizzle)
|
|
@@ -220,7 +223,7 @@ Covers:
|
|
|
220
223
|
|
|
221
224
|
See [ROADMAP.md](./ROADMAP.md) for the full picture (shipped items + items under consideration with rationale).
|
|
222
225
|
|
|
223
|
-
**Shipped:** automated caniemail.com data sync · GitHub Actions integration via `@emailens/cli` and the [Marketplace Action](https://github.com/marketplace/actions/emailens-email-preview-check) · AI-powered fix generation · compile module for JSX/MJML/Maizzle.
|
|
226
|
+
**Shipped:** Outlook VML structural validation (`checkVml`, verified against the Word engine) · automated caniemail.com data sync · GitHub Actions integration via `@emailens/cli` and the [Marketplace Action](https://github.com/marketplace/actions/emailens-email-preview-check) · AI-powered fix generation · compile module for JSX/MJML/Maizzle.
|
|
224
227
|
|
|
225
228
|
**Considering:** Outlook VML auto-generation · plugin system for custom analyzers · MJML/Maizzle source-level linting · ESLint plugin · spam corpus tuning · dark-mode accuracy tests.
|
|
226
229
|
|
package/dist/compile/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-
|
|
1
|
+
import { x as InputFormat } from '../types-bvGDId6f.cjs';
|
|
2
|
+
export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-DuZAgJtw.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Compile an MJML source string into an HTML email string.
|
package/dist/compile/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-
|
|
1
|
+
import { x as InputFormat } from '../types-bvGDId6f.js';
|
|
2
|
+
export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-BROBcC8u.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Compile an MJML source string into an HTML email string.
|
package/dist/index.cjs
CHANGED
|
@@ -79,6 +79,7 @@ __export(index_exports, {
|
|
|
79
79
|
analyzeEmail: () => analyzeEmail,
|
|
80
80
|
analyzeImages: () => analyzeImages,
|
|
81
81
|
analyzeSpam: () => analyzeSpam,
|
|
82
|
+
arcsizeToRadius: () => arcsizeToRadius,
|
|
82
83
|
auditEmail: () => auditEmail,
|
|
83
84
|
caveatApplies: () => caveatApplies,
|
|
84
85
|
checkAccessibility: () => checkAccessibility,
|
|
@@ -90,6 +91,7 @@ __export(index_exports, {
|
|
|
90
91
|
checkSize: () => checkSize,
|
|
91
92
|
checkTemplateVariables: () => checkTemplateVariables,
|
|
92
93
|
checkVisual: () => checkVisual,
|
|
94
|
+
checkVml: () => checkVml,
|
|
93
95
|
colorDistance: () => colorDistance,
|
|
94
96
|
contrastRatio: () => contrastRatio,
|
|
95
97
|
createSession: () => createSession,
|
|
@@ -109,6 +111,8 @@ __export(index_exports, {
|
|
|
109
111
|
heuristicTokenCount: () => heuristicTokenCount,
|
|
110
112
|
parseColor: () => parseColor,
|
|
111
113
|
relativeLuminance: () => relativeLuminance,
|
|
114
|
+
renderOutlookBranch: () => renderOutlookBranch,
|
|
115
|
+
resolveMsoBranch: () => resolveMsoBranch,
|
|
112
116
|
rgbToOklab: () => rgbToOklab,
|
|
113
117
|
simulateDarkMode: () => simulateDarkMode,
|
|
114
118
|
structuralWarnings: () => structuralWarnings,
|
|
@@ -116,6 +120,7 @@ __export(index_exports, {
|
|
|
116
120
|
transformForAllClients: () => transformForAllClients,
|
|
117
121
|
transformForClient: () => transformForClient,
|
|
118
122
|
validateLinks: () => validateLinks,
|
|
123
|
+
vmlToCss: () => vmlToCss,
|
|
119
124
|
warningsForClient: () => warningsForClient,
|
|
120
125
|
wcagGrade: () => wcagGrade
|
|
121
126
|
});
|
|
@@ -9854,6 +9859,114 @@ function downlevelCSS(html) {
|
|
|
9854
9859
|
return $.html();
|
|
9855
9860
|
}
|
|
9856
9861
|
|
|
9862
|
+
// src/vml-render.ts
|
|
9863
|
+
function px(value) {
|
|
9864
|
+
if (!value) return void 0;
|
|
9865
|
+
const m = value.trim().match(/^(-?[\d.]+)(?:px)?$/i);
|
|
9866
|
+
return m ? parseFloat(m[1]) : void 0;
|
|
9867
|
+
}
|
|
9868
|
+
function styleProps(style) {
|
|
9869
|
+
const out = {};
|
|
9870
|
+
for (const decl of style.split(";")) {
|
|
9871
|
+
const i = decl.indexOf(":");
|
|
9872
|
+
if (i > 0) out[decl.slice(0, i).trim().toLowerCase()] = decl.slice(i + 1).trim();
|
|
9873
|
+
}
|
|
9874
|
+
return out;
|
|
9875
|
+
}
|
|
9876
|
+
function attrs(raw) {
|
|
9877
|
+
var _a, _b;
|
|
9878
|
+
const out = {};
|
|
9879
|
+
for (const m of raw.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)'/g)) {
|
|
9880
|
+
out[((_a = m[1]) != null ? _a : m[3]).toLowerCase()] = (_b = m[2]) != null ? _b : m[4];
|
|
9881
|
+
}
|
|
9882
|
+
return out;
|
|
9883
|
+
}
|
|
9884
|
+
function arcsizeToRadius(arcsize, width, height) {
|
|
9885
|
+
const raw = arcsize.trim();
|
|
9886
|
+
const pct = raw.endsWith("%") ? parseFloat(raw) : parseFloat(raw) * 100;
|
|
9887
|
+
if (!Number.isFinite(pct)) return 0;
|
|
9888
|
+
return Math.min(100, Math.max(0, pct)) / 100 * (Math.min(width, height) / 2);
|
|
9889
|
+
}
|
|
9890
|
+
function resolveMsoBranch(html) {
|
|
9891
|
+
return html.replace(/<!--\[if\s*!\s*(?:mso|vml)[^\]]*\]><!-->([\s\S]*?)<!--<!\[endif\]-->/gi, "").replace(/<!--\[if[^\]]*(?:mso|vml)[^\]]*\]>([\s\S]*?)<!\[endif\]-->/gi, "$1");
|
|
9892
|
+
}
|
|
9893
|
+
function vmlToCss(html) {
|
|
9894
|
+
let out = html;
|
|
9895
|
+
out = out.replace(
|
|
9896
|
+
/<v:roundrect([^>]*)>([\s\S]*?)<\/v:roundrect>/gi,
|
|
9897
|
+
(_m, rawAttrs, inner) => {
|
|
9898
|
+
var _a, _b, _c, _d, _e, _f;
|
|
9899
|
+
const a = attrs(rawAttrs);
|
|
9900
|
+
const s = styleProps((_a = a.style) != null ? _a : "");
|
|
9901
|
+
const w = (_b = px(s.width)) != null ? _b : 0;
|
|
9902
|
+
const h = (_c = px(s.height)) != null ? _c : 0;
|
|
9903
|
+
const radius = a.arcsize ? arcsizeToRadius(a.arcsize, w, h) : 0.2 * (Math.min(w, h) / 2);
|
|
9904
|
+
const fill = (_d = a.fillcolor) != null ? _d : "transparent";
|
|
9905
|
+
const stroked = !(a.stroke === "f" || a.stroke === "false");
|
|
9906
|
+
const middle = /middle/i.test((_e = s["v-text-anchor"]) != null ? _e : "");
|
|
9907
|
+
const label = inner.replace(/<\/?(?:w:anchorlock|center)[^>]*>/gi, "").trim();
|
|
9908
|
+
const box = [
|
|
9909
|
+
w ? `width:${w}px` : "",
|
|
9910
|
+
h ? `height:${h}px` : "",
|
|
9911
|
+
`border-radius:${radius}px`,
|
|
9912
|
+
`background:${fill}`,
|
|
9913
|
+
stroked ? `border:1px solid ${(_f = a.strokecolor) != null ? _f : "#000"}` : "border:none",
|
|
9914
|
+
"display:inline-flex",
|
|
9915
|
+
"justify-content:center",
|
|
9916
|
+
`align-items:${middle ? "center" : "flex-start"}`,
|
|
9917
|
+
"overflow:hidden",
|
|
9918
|
+
"text-align:center"
|
|
9919
|
+
].filter(Boolean).join(";");
|
|
9920
|
+
const href = a.href ? ` data-href="${a.href}"` : "";
|
|
9921
|
+
return `<div data-vml="roundrect"${href} style="${box}">${label}</div>`;
|
|
9922
|
+
}
|
|
9923
|
+
);
|
|
9924
|
+
out = out.replace(
|
|
9925
|
+
/<v:rect([^>]*)>([\s\S]*?)<\/v:rect>/gi,
|
|
9926
|
+
(_m, rawAttrs, inner) => {
|
|
9927
|
+
var _a, _b, _c, _d, _e, _f;
|
|
9928
|
+
const a = attrs(rawAttrs);
|
|
9929
|
+
const s = styleProps((_a = a.style) != null ? _a : "");
|
|
9930
|
+
const w = px(s.width);
|
|
9931
|
+
const h = px(s.height);
|
|
9932
|
+
const fillTag = inner.match(/<v:fill([^>]*?)\/?>/i);
|
|
9933
|
+
const f = fillTag ? attrs(fillTag[1]) : {};
|
|
9934
|
+
const type = ((_b = f.type) != null ? _b : "solid").toLowerCase();
|
|
9935
|
+
let background = "";
|
|
9936
|
+
if (f.src && type === "tile") {
|
|
9937
|
+
background = `background-image:url('${f.src}');background-repeat:repeat;`;
|
|
9938
|
+
} else if (f.src) {
|
|
9939
|
+
background = `background-image:url('${f.src}');background-size:cover;background-position:center;`;
|
|
9940
|
+
} else if (type === "gradient" || type === "gradientradial") {
|
|
9941
|
+
const from = (_d = (_c = f.color) != null ? _c : a.fillcolor) != null ? _d : "transparent";
|
|
9942
|
+
const to = (_e = f.color2) != null ? _e : from;
|
|
9943
|
+
const angle = f.angle ? `${parseFloat(f.angle)}deg` : "180deg";
|
|
9944
|
+
background = type === "gradientradial" ? `background-image:radial-gradient(${from},${to});` : `background-image:linear-gradient(${angle},${from},${to});`;
|
|
9945
|
+
}
|
|
9946
|
+
const solid = (_f = f.color) != null ? _f : a.fillcolor;
|
|
9947
|
+
const tb = inner.match(/<v:textbox([^>]*)>([\s\S]*?)<\/v:textbox>/i);
|
|
9948
|
+
const inset = tb ? attrs(tb[1]).inset : void 0;
|
|
9949
|
+
const padding = inset ? inset.split(",").map((p) => {
|
|
9950
|
+
var _a2;
|
|
9951
|
+
return `${(_a2 = px(p.trim())) != null ? _a2 : 0}px`;
|
|
9952
|
+
}).join(" ") : "0";
|
|
9953
|
+
const content = tb ? tb[2] : inner.replace(/<v:fill[^>]*?\/?>/gi, "");
|
|
9954
|
+
const box = [
|
|
9955
|
+
w !== void 0 ? `width:${w}px` : "",
|
|
9956
|
+
h !== void 0 ? `height:${h}px` : "",
|
|
9957
|
+
solid ? `background-color:${solid}` : "",
|
|
9958
|
+
`padding:${padding}`
|
|
9959
|
+
].filter(Boolean).join(";");
|
|
9960
|
+
return `<div data-vml="rect" style="${background}${box}">${content}</div>`;
|
|
9961
|
+
}
|
|
9962
|
+
);
|
|
9963
|
+
return out.replace(/<\/?v:(?:fill|textbox|stroke|shadow|imagedata|path|formulas|handles)[^>]*?\/?>/gi, "");
|
|
9964
|
+
}
|
|
9965
|
+
function renderOutlookBranch(html) {
|
|
9966
|
+
if (!/<!--\[if/i.test(html)) return html;
|
|
9967
|
+
return vmlToCss(resolveMsoBranch(html));
|
|
9968
|
+
}
|
|
9969
|
+
|
|
9857
9970
|
// src/constants.ts
|
|
9858
9971
|
var MAX_HTML_SIZE = 2 * 1024 * 1024;
|
|
9859
9972
|
var MAX_WARNING_LOCATIONS = 100;
|
|
@@ -9907,6 +10020,7 @@ var EMPTY_INBOX_PREVIEW = { subject: null, preheader: null, subjectLength: 0, pr
|
|
|
9907
10020
|
var EMPTY_SIZE = { htmlBytes: 0, humanSize: "0 B", clipped: false, issues: [] };
|
|
9908
10021
|
var EMPTY_TEMPLATE = { unresolvedCount: 0, issues: [] };
|
|
9909
10022
|
var EMPTY_OVERFLOW = { hasOverflow: false, issues: [] };
|
|
10023
|
+
var EMPTY_VML = { hasVml: false, issues: [] };
|
|
9910
10024
|
var EMPTY_VISUAL = { issues: [] };
|
|
9911
10025
|
var EMAIL_MAX_WIDTH = 600;
|
|
9912
10026
|
var UNBREAKABLE_STRING_LENGTH = 30;
|
|
@@ -10576,7 +10690,8 @@ function transformForClient(html, clientId, framework) {
|
|
|
10576
10690
|
]
|
|
10577
10691
|
};
|
|
10578
10692
|
}
|
|
10579
|
-
const
|
|
10693
|
+
const source = clientId === "outlook-windows-legacy" ? renderOutlookBranch(html) : html;
|
|
10694
|
+
const downleveled = downlevelCSS(source);
|
|
10580
10695
|
return applyTransform(downleveled, config, framework);
|
|
10581
10696
|
}
|
|
10582
10697
|
function transformForAllClients(html, framework) {
|
|
@@ -11115,11 +11230,11 @@ function locOfElement(el) {
|
|
|
11115
11230
|
if (!raw) return void 0;
|
|
11116
11231
|
return toLoc((_a = raw.startTag) != null ? _a : raw);
|
|
11117
11232
|
}
|
|
11118
|
-
function locOfAttr(el,
|
|
11233
|
+
function locOfAttr(el, attr2) {
|
|
11119
11234
|
var _a, _b, _c, _d;
|
|
11120
11235
|
const raw = el == null ? void 0 : el.sourceCodeLocation;
|
|
11121
11236
|
if (!raw) return void 0;
|
|
11122
|
-
const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[
|
|
11237
|
+
const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[attr2]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr2];
|
|
11123
11238
|
return attrLoc ? toLoc(attrLoc) : locOfElement(el);
|
|
11124
11239
|
}
|
|
11125
11240
|
function locInAttr(attrLoc, source, property, occurrence = 0) {
|
|
@@ -13314,9 +13429,9 @@ function mediaApplies(prelude, ctx) {
|
|
|
13314
13429
|
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13315
13430
|
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13316
13431
|
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13317
|
-
const
|
|
13318
|
-
if (
|
|
13319
|
-
if (m[1] === "max" ? ctx.width >
|
|
13432
|
+
const px2 = mediaPx(m[2]);
|
|
13433
|
+
if (px2 === null) continue;
|
|
13434
|
+
if (m[1] === "max" ? ctx.width > px2 : ctx.width < px2) return false;
|
|
13320
13435
|
}
|
|
13321
13436
|
return true;
|
|
13322
13437
|
}
|
|
@@ -13537,10 +13652,10 @@ function resolveBackground($, el, cascade) {
|
|
|
13537
13652
|
function isLargeText(size, weight) {
|
|
13538
13653
|
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13539
13654
|
if (!match) return false;
|
|
13540
|
-
const
|
|
13655
|
+
const px2 = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13541
13656
|
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13542
13657
|
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13543
|
-
return
|
|
13658
|
+
return px2 >= 18 || px2 >= 14 && bold;
|
|
13544
13659
|
}
|
|
13545
13660
|
function inherited($, el, cascade, prop) {
|
|
13546
13661
|
var _a, _b;
|
|
@@ -14261,23 +14376,23 @@ function checkTemplateVariablesFromDom($, source) {
|
|
|
14261
14376
|
const attrSelectors = ["[href]", "[src]", "[alt]"];
|
|
14262
14377
|
for (const sel of attrSelectors) {
|
|
14263
14378
|
$(sel).each((_, el) => {
|
|
14264
|
-
const
|
|
14265
|
-
for (const
|
|
14266
|
-
const value = $(el).attr(
|
|
14379
|
+
const attrs2 = ["href", "src", "alt"];
|
|
14380
|
+
for (const attr2 of attrs2) {
|
|
14381
|
+
const value = $(el).attr(attr2);
|
|
14267
14382
|
if (!value) continue;
|
|
14268
14383
|
for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
|
|
14269
14384
|
pattern.lastIndex = 0;
|
|
14270
14385
|
let match;
|
|
14271
14386
|
while ((match = pattern.exec(value)) !== null) {
|
|
14272
14387
|
const variable = match[0];
|
|
14273
|
-
const key = `attr:${
|
|
14388
|
+
const key = `attr:${attr2}:${variable}`;
|
|
14274
14389
|
if (seen.has(key)) continue;
|
|
14275
14390
|
seen.add(key);
|
|
14276
|
-
const loc = locOfAttr(el,
|
|
14391
|
+
const loc = locOfAttr(el, attr2);
|
|
14277
14392
|
issues.push(__spreadValues({
|
|
14278
14393
|
rule: "unresolved-variable",
|
|
14279
14394
|
severity: "error",
|
|
14280
|
-
message: `Unresolved ${label} variable "${variable}" found in ${
|
|
14395
|
+
message: `Unresolved ${label} variable "${variable}" found in ${attr2} attribute.`,
|
|
14281
14396
|
variable,
|
|
14282
14397
|
location: "attribute"
|
|
14283
14398
|
}, loc ? { loc } : {}));
|
|
@@ -14303,8 +14418,8 @@ function fixedPxWidth($el) {
|
|
|
14303
14418
|
const style = $el.attr("style") || "";
|
|
14304
14419
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
14305
14420
|
if (styleMatch) return parseInt(styleMatch[1], 10);
|
|
14306
|
-
const
|
|
14307
|
-
if (
|
|
14421
|
+
const attr2 = $el.attr("width");
|
|
14422
|
+
if (attr2 && /^\d+$/.test(attr2.trim())) return parseInt(attr2.trim(), 10);
|
|
14308
14423
|
return null;
|
|
14309
14424
|
}
|
|
14310
14425
|
function isFluid(style) {
|
|
@@ -14444,6 +14559,193 @@ function checkOverflow(html, options) {
|
|
|
14444
14559
|
);
|
|
14445
14560
|
}
|
|
14446
14561
|
|
|
14562
|
+
// src/vml-checker.ts
|
|
14563
|
+
var SHAPE_TAGS = /* @__PURE__ */ new Set([
|
|
14564
|
+
"rect",
|
|
14565
|
+
"roundrect",
|
|
14566
|
+
"oval",
|
|
14567
|
+
"line",
|
|
14568
|
+
"polyline",
|
|
14569
|
+
"curve",
|
|
14570
|
+
"arc",
|
|
14571
|
+
"shape",
|
|
14572
|
+
"image",
|
|
14573
|
+
"background"
|
|
14574
|
+
]);
|
|
14575
|
+
var GROUP_TAGS = /* @__PURE__ */ new Set(["group"]);
|
|
14576
|
+
function msoBlocks(html) {
|
|
14577
|
+
const blocks = [];
|
|
14578
|
+
const re = /<!--\[if([^\]]*)\]>([\s\S]*?)<!\[endif\]-->/g;
|
|
14579
|
+
let m;
|
|
14580
|
+
while ((m = re.exec(html)) !== null) {
|
|
14581
|
+
const condition = m[1];
|
|
14582
|
+
if (/!\s*(mso|vml)/i.test(condition)) continue;
|
|
14583
|
+
if (!/mso|vml/i.test(condition)) continue;
|
|
14584
|
+
blocks.push({ inner: m[2], offset: m.index + m[0].length - m[2].length - "<![endif]-->".length });
|
|
14585
|
+
}
|
|
14586
|
+
return blocks;
|
|
14587
|
+
}
|
|
14588
|
+
function vmlTags(blocks) {
|
|
14589
|
+
const tags = [];
|
|
14590
|
+
const re = /<(\/?)v:([a-z]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
|
|
14591
|
+
for (const block of blocks) {
|
|
14592
|
+
let m;
|
|
14593
|
+
re.lastIndex = 0;
|
|
14594
|
+
while ((m = re.exec(block.inner)) !== null) {
|
|
14595
|
+
tags.push({
|
|
14596
|
+
name: m[2].toLowerCase(),
|
|
14597
|
+
closing: m[1] === "/",
|
|
14598
|
+
selfClosing: m[4] === "/",
|
|
14599
|
+
attrs: m[3],
|
|
14600
|
+
offset: block.offset + m.index,
|
|
14601
|
+
length: m[0].length
|
|
14602
|
+
});
|
|
14603
|
+
}
|
|
14604
|
+
}
|
|
14605
|
+
return tags;
|
|
14606
|
+
}
|
|
14607
|
+
function locAt(source, offset, length) {
|
|
14608
|
+
if (source === void 0) return void 0;
|
|
14609
|
+
const start = positionOf(source, offset);
|
|
14610
|
+
const end = positionOf(source, offset + length);
|
|
14611
|
+
return {
|
|
14612
|
+
line: start.line,
|
|
14613
|
+
column: start.column,
|
|
14614
|
+
endLine: end.line,
|
|
14615
|
+
endColumn: end.column,
|
|
14616
|
+
offset,
|
|
14617
|
+
length
|
|
14618
|
+
};
|
|
14619
|
+
}
|
|
14620
|
+
function attr(attrs2, name) {
|
|
14621
|
+
var _a;
|
|
14622
|
+
const m = attrs2.match(
|
|
14623
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
|
|
14624
|
+
);
|
|
14625
|
+
return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
|
|
14626
|
+
}
|
|
14627
|
+
function add(issues, seen, key, issue) {
|
|
14628
|
+
const existing = seen.get(key);
|
|
14629
|
+
if (existing) {
|
|
14630
|
+
if (!issue.loc || !existing.locs) return;
|
|
14631
|
+
if (existing.locs.some((l) => l.offset === issue.loc.offset)) return;
|
|
14632
|
+
if (existing.locs.length >= MAX_WARNING_LOCATIONS) {
|
|
14633
|
+
existing.locsTruncated = true;
|
|
14634
|
+
return;
|
|
14635
|
+
}
|
|
14636
|
+
existing.locs.push(issue.loc);
|
|
14637
|
+
return;
|
|
14638
|
+
}
|
|
14639
|
+
seen.set(key, issue);
|
|
14640
|
+
issues.push(issue);
|
|
14641
|
+
}
|
|
14642
|
+
function withLoc(issue, loc) {
|
|
14643
|
+
return loc ? __spreadProps(__spreadValues({}, issue), { loc, locs: [loc] }) : issue;
|
|
14644
|
+
}
|
|
14645
|
+
var TEXT_HOSTS = /<\s*(center|v:textbox|div|p|table|h[1-6]|span|font|a)\b/i;
|
|
14646
|
+
function checkLooseText(issues, seen, open, inner, source) {
|
|
14647
|
+
if (TEXT_HOSTS.test(inner)) return;
|
|
14648
|
+
const text = inner.replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]*>/g, "").replace(/ |\s/g, "");
|
|
14649
|
+
if (text === "") return;
|
|
14650
|
+
add(issues, seen, `loosetext:${open.name}`, withLoc({
|
|
14651
|
+
rule: "vml-unrendered-text",
|
|
14652
|
+
severity: "error",
|
|
14653
|
+
message: `<v:${open.name}> has label text with no element around it, which Outlook does not draw.`,
|
|
14654
|
+
detail: `Verified in Outlook Classic: the shape renders its fill and shape correctly and shows no text at all, so the reader gets a blank coloured block where the label should be. Nothing errors and the HTML fallback still reads correctly in every other client, so this ships unnoticed. Wrap the text in <center> (what the bulletproof-button pattern uses) or in <v:textbox>.`
|
|
14655
|
+
}, locAt(source, open.offset, open.length)));
|
|
14656
|
+
}
|
|
14657
|
+
function checkVml(html, options) {
|
|
14658
|
+
if (!html || !html.trim()) return EMPTY_VML;
|
|
14659
|
+
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
14660
|
+
const tags = vmlTags(msoBlocks(html));
|
|
14661
|
+
if (tags.length === 0) return EMPTY_VML;
|
|
14662
|
+
const issues = [];
|
|
14663
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14664
|
+
const openShapes = [];
|
|
14665
|
+
let groupDepth = 0;
|
|
14666
|
+
for (const tag of tags) {
|
|
14667
|
+
const { name } = tag;
|
|
14668
|
+
const isShape = SHAPE_TAGS.has(name);
|
|
14669
|
+
const isGroup = GROUP_TAGS.has(name);
|
|
14670
|
+
const loc = locAt(source, tag.offset, tag.length);
|
|
14671
|
+
if (tag.closing) {
|
|
14672
|
+
if (isGroup) groupDepth = Math.max(0, groupDepth - 1);
|
|
14673
|
+
if (isShape) {
|
|
14674
|
+
const idx = openShapes.map((t) => t.name).lastIndexOf(name);
|
|
14675
|
+
if (idx !== -1) {
|
|
14676
|
+
const open = openShapes[idx];
|
|
14677
|
+
const inner = html.slice(open.offset + open.length, tag.offset);
|
|
14678
|
+
checkLooseText(issues, seen, open, inner, source);
|
|
14679
|
+
}
|
|
14680
|
+
if (idx === -1) {
|
|
14681
|
+
add(issues, seen, `stray:${name}`, withLoc({
|
|
14682
|
+
rule: "vml-unbalanced-tag",
|
|
14683
|
+
severity: "error",
|
|
14684
|
+
message: `Closing </v:${name}> has no matching opening tag.`,
|
|
14685
|
+
detail: `Outlook stops rendering the shape when VML tags do not balance. Check that every conditional-comment block opens and closes the tags it is responsible for.`
|
|
14686
|
+
}, loc));
|
|
14687
|
+
} else {
|
|
14688
|
+
openShapes.splice(idx, 1);
|
|
14689
|
+
}
|
|
14690
|
+
}
|
|
14691
|
+
continue;
|
|
14692
|
+
}
|
|
14693
|
+
if (isGroup && !tag.selfClosing) groupDepth++;
|
|
14694
|
+
if (isShape) {
|
|
14695
|
+
const parent = openShapes[openShapes.length - 1];
|
|
14696
|
+
if (parent && groupDepth === 0) {
|
|
14697
|
+
add(issues, seen, `nested:${parent.name}>${name}`, withLoc({
|
|
14698
|
+
rule: "vml-nested-shape",
|
|
14699
|
+
severity: "error",
|
|
14700
|
+
message: `<v:${name}> is nested inside <v:${parent.name}>. Outlook does not support nesting one VML shape inside another.`,
|
|
14701
|
+
detail: `Verified in Outlook Classic, and the damage is not local. Three things happen: the containing <v:${parent.name}> does not render at all (its fill and everything inside it disappear, leaving the inner shape stranded); the table structure around it terminates early, so content *after* the shape falls out of the layout and renders outside the email frame; and every VML shape further down the email stops drawing its text, so buttons and headings after this point ship as blank coloured blocks. Confirmed with byte-identical probes either side of one nested shape: the one before it renders its labels, the one after it does not. Every other client renders the HTML fallback correctly, which is why none of this is visible outside the Word engine. Fix: lift the inner shape out of <v:${parent.name}>, or drop the container shape and keep the inner one (a framed background can degrade to a solid fill colour on the <td> instead).`
|
|
14702
|
+
}, loc));
|
|
14703
|
+
}
|
|
14704
|
+
if (!tag.selfClosing) openShapes.push(tag);
|
|
14705
|
+
}
|
|
14706
|
+
const style = attr(tag.attrs, "style");
|
|
14707
|
+
if (style && (isShape || isGroup)) {
|
|
14708
|
+
for (const prop of ["width", "height"]) {
|
|
14709
|
+
const m = style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]*)`, "i"));
|
|
14710
|
+
if (!m) continue;
|
|
14711
|
+
const value = m[1].trim();
|
|
14712
|
+
if (value === "" || /^[a-z%]+$/i.test(value)) {
|
|
14713
|
+
add(issues, seen, `dim:${name}:${prop}:${value}`, withLoc({
|
|
14714
|
+
rule: "vml-invalid-dimension",
|
|
14715
|
+
severity: "error",
|
|
14716
|
+
message: `<v:${name}> has an invalid ${prop}: "${prop}:${value}" is missing its number.`,
|
|
14717
|
+
detail: `Verified in Outlook Classic: the shape still draws, but at a size Outlook picks rather than the one you meant, and the content inside is clipped to it \u2014 roughly half the intended height in the case measured, with the text inside cut off. It fails quietly: there is no gap or broken image to notice, just content that is silently missing. Usually a template variable that resolved to an empty string. Set an explicit value in pixels, e.g. ${prop}:400px.`
|
|
14718
|
+
}, loc));
|
|
14719
|
+
}
|
|
14720
|
+
}
|
|
14721
|
+
}
|
|
14722
|
+
if (name === "roundrect") {
|
|
14723
|
+
const arcsize = attr(tag.attrs, "arcsize");
|
|
14724
|
+
if (arcsize !== void 0) {
|
|
14725
|
+
const raw = arcsize.trim();
|
|
14726
|
+
const pct = raw.endsWith("%") ? parseFloat(raw) : parseFloat(raw) * 100;
|
|
14727
|
+
if (Number.isFinite(pct) && (pct < 0 || pct > 100)) {
|
|
14728
|
+
add(issues, seen, `arcsize:${raw}`, withLoc({
|
|
14729
|
+
rule: "vml-arcsize-range",
|
|
14730
|
+
severity: "warning",
|
|
14731
|
+
message: `<v:roundrect> has arcsize="${raw}", outside the valid 0%\u2013100% range.`,
|
|
14732
|
+
detail: `arcsize is a fraction of half the shape's smaller side: 0% is square, 100% is fully circular. Verified in Outlook Classic: out-of-range values are clamped, so 120% draws exactly the same corner as 100%. Nothing visibly breaks today, which is why this is a warning and not an error \u2014 but the radius you get is the renderer's clamp rather than a value you chose, and it is not guaranteed across clients. For a fully rounded button, say arcsize="100%".`
|
|
14733
|
+
}, loc));
|
|
14734
|
+
}
|
|
14735
|
+
}
|
|
14736
|
+
}
|
|
14737
|
+
}
|
|
14738
|
+
for (const tag of openShapes) {
|
|
14739
|
+
add(issues, seen, `unclosed:${tag.name}`, withLoc({
|
|
14740
|
+
rule: "vml-unbalanced-tag",
|
|
14741
|
+
severity: "error",
|
|
14742
|
+
message: `<v:${tag.name}> is never closed.`,
|
|
14743
|
+
detail: `Outlook needs the matching </v:${tag.name}>, usually in a later conditional-comment block. Without it the shape swallows the rest of the email.`
|
|
14744
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14745
|
+
}
|
|
14746
|
+
return { hasVml: true, issues };
|
|
14747
|
+
}
|
|
14748
|
+
|
|
14447
14749
|
// src/visual-checker.ts
|
|
14448
14750
|
var csstree8 = __toESM(require("css-tree"), 1);
|
|
14449
14751
|
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
@@ -14625,8 +14927,8 @@ function radiusLengths(value) {
|
|
|
14625
14927
|
return lengths.filter((v) => {
|
|
14626
14928
|
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14627
14929
|
if (v === "50%") return false;
|
|
14628
|
-
const
|
|
14629
|
-
return !(v.endsWith("px") && Number.isFinite(
|
|
14930
|
+
const px2 = parseFloat(v);
|
|
14931
|
+
return !(v.endsWith("px") && Number.isFinite(px2) && px2 >= 500);
|
|
14630
14932
|
});
|
|
14631
14933
|
}
|
|
14632
14934
|
function collect($) {
|
|
@@ -14774,6 +15076,7 @@ var EMPTY_AUDIT = {
|
|
|
14774
15076
|
templateVariables: EMPTY_TEMPLATE,
|
|
14775
15077
|
overflow: EMPTY_OVERFLOW,
|
|
14776
15078
|
visual: EMPTY_VISUAL,
|
|
15079
|
+
vml: EMPTY_VML,
|
|
14777
15080
|
darkContrast: [],
|
|
14778
15081
|
mobileContrast: [],
|
|
14779
15082
|
design: EMPTY_DESIGN
|
|
@@ -14801,13 +15104,14 @@ function runAudit($, html, framework, options) {
|
|
|
14801
15104
|
const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
|
|
14802
15105
|
const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
|
|
14803
15106
|
const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
|
|
15107
|
+
const vml = skip.has("vml") ? EMPTY_VML : checkVml(html, { positions: !!source });
|
|
14804
15108
|
const darkContrast = skip.has("darkContrast") || skip.has("accessibility") ? [] : dedupeByElement([
|
|
14805
15109
|
...checkDarkModeContrast(html, accessibility.issues),
|
|
14806
15110
|
...checkDarkStylesContrastFromDom($, accessibility.issues)
|
|
14807
15111
|
]);
|
|
14808
15112
|
const design = skip.has("design") ? EMPTY_DESIGN : checkDesignConsistencyFromDom($);
|
|
14809
15113
|
const mobileContrast = skip.has("mobileContrast") || skip.has("accessibility") ? [] : checkMobileContrastFromDom($, accessibility.issues);
|
|
14810
|
-
return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, darkContrast, mobileContrast, design };
|
|
15114
|
+
return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, vml, darkContrast, mobileContrast, design };
|
|
14811
15115
|
}
|
|
14812
15116
|
function auditEmail(html, options) {
|
|
14813
15117
|
return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
|
|
@@ -15030,6 +15334,7 @@ var CompileError = class extends Error {
|
|
|
15030
15334
|
analyzeEmail,
|
|
15031
15335
|
analyzeImages,
|
|
15032
15336
|
analyzeSpam,
|
|
15337
|
+
arcsizeToRadius,
|
|
15033
15338
|
auditEmail,
|
|
15034
15339
|
caveatApplies,
|
|
15035
15340
|
checkAccessibility,
|
|
@@ -15041,6 +15346,7 @@ var CompileError = class extends Error {
|
|
|
15041
15346
|
checkSize,
|
|
15042
15347
|
checkTemplateVariables,
|
|
15043
15348
|
checkVisual,
|
|
15349
|
+
checkVml,
|
|
15044
15350
|
colorDistance,
|
|
15045
15351
|
contrastRatio,
|
|
15046
15352
|
createSession,
|
|
@@ -15060,6 +15366,8 @@ var CompileError = class extends Error {
|
|
|
15060
15366
|
heuristicTokenCount,
|
|
15061
15367
|
parseColor,
|
|
15062
15368
|
relativeLuminance,
|
|
15369
|
+
renderOutlookBranch,
|
|
15370
|
+
resolveMsoBranch,
|
|
15063
15371
|
rgbToOklab,
|
|
15064
15372
|
simulateDarkMode,
|
|
15065
15373
|
structuralWarnings,
|
|
@@ -15067,6 +15375,7 @@ var CompileError = class extends Error {
|
|
|
15067
15375
|
transformForAllClients,
|
|
15068
15376
|
transformForClient,
|
|
15069
15377
|
validateLinks,
|
|
15378
|
+
vmlToCss,
|
|
15070
15379
|
warningsForClient,
|
|
15071
15380
|
wcagGrade
|
|
15072
15381
|
});
|