@emailens/engine 0.11.1 → 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/index.cjs +129 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +125 -12
- package/dist/index.js.map +1 -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/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,
|
|
@@ -110,6 +111,8 @@ __export(index_exports, {
|
|
|
110
111
|
heuristicTokenCount: () => heuristicTokenCount,
|
|
111
112
|
parseColor: () => parseColor,
|
|
112
113
|
relativeLuminance: () => relativeLuminance,
|
|
114
|
+
renderOutlookBranch: () => renderOutlookBranch,
|
|
115
|
+
resolveMsoBranch: () => resolveMsoBranch,
|
|
113
116
|
rgbToOklab: () => rgbToOklab,
|
|
114
117
|
simulateDarkMode: () => simulateDarkMode,
|
|
115
118
|
structuralWarnings: () => structuralWarnings,
|
|
@@ -117,6 +120,7 @@ __export(index_exports, {
|
|
|
117
120
|
transformForAllClients: () => transformForAllClients,
|
|
118
121
|
transformForClient: () => transformForClient,
|
|
119
122
|
validateLinks: () => validateLinks,
|
|
123
|
+
vmlToCss: () => vmlToCss,
|
|
120
124
|
warningsForClient: () => warningsForClient,
|
|
121
125
|
wcagGrade: () => wcagGrade
|
|
122
126
|
});
|
|
@@ -9855,6 +9859,114 @@ function downlevelCSS(html) {
|
|
|
9855
9859
|
return $.html();
|
|
9856
9860
|
}
|
|
9857
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
|
+
|
|
9858
9970
|
// src/constants.ts
|
|
9859
9971
|
var MAX_HTML_SIZE = 2 * 1024 * 1024;
|
|
9860
9972
|
var MAX_WARNING_LOCATIONS = 100;
|
|
@@ -10578,7 +10690,8 @@ function transformForClient(html, clientId, framework) {
|
|
|
10578
10690
|
]
|
|
10579
10691
|
};
|
|
10580
10692
|
}
|
|
10581
|
-
const
|
|
10693
|
+
const source = clientId === "outlook-windows-legacy" ? renderOutlookBranch(html) : html;
|
|
10694
|
+
const downleveled = downlevelCSS(source);
|
|
10582
10695
|
return applyTransform(downleveled, config, framework);
|
|
10583
10696
|
}
|
|
10584
10697
|
function transformForAllClients(html, framework) {
|
|
@@ -13316,9 +13429,9 @@ function mediaApplies(prelude, ctx) {
|
|
|
13316
13429
|
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13317
13430
|
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13318
13431
|
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13319
|
-
const
|
|
13320
|
-
if (
|
|
13321
|
-
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;
|
|
13322
13435
|
}
|
|
13323
13436
|
return true;
|
|
13324
13437
|
}
|
|
@@ -13539,10 +13652,10 @@ function resolveBackground($, el, cascade) {
|
|
|
13539
13652
|
function isLargeText(size, weight) {
|
|
13540
13653
|
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13541
13654
|
if (!match) return false;
|
|
13542
|
-
const
|
|
13655
|
+
const px2 = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13543
13656
|
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13544
13657
|
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13545
|
-
return
|
|
13658
|
+
return px2 >= 18 || px2 >= 14 && bold;
|
|
13546
13659
|
}
|
|
13547
13660
|
function inherited($, el, cascade, prop) {
|
|
13548
13661
|
var _a, _b;
|
|
@@ -14263,8 +14376,8 @@ function checkTemplateVariablesFromDom($, source) {
|
|
|
14263
14376
|
const attrSelectors = ["[href]", "[src]", "[alt]"];
|
|
14264
14377
|
for (const sel of attrSelectors) {
|
|
14265
14378
|
$(sel).each((_, el) => {
|
|
14266
|
-
const
|
|
14267
|
-
for (const attr2 of
|
|
14379
|
+
const attrs2 = ["href", "src", "alt"];
|
|
14380
|
+
for (const attr2 of attrs2) {
|
|
14268
14381
|
const value = $(el).attr(attr2);
|
|
14269
14382
|
if (!value) continue;
|
|
14270
14383
|
for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
|
|
@@ -14504,9 +14617,9 @@ function locAt(source, offset, length) {
|
|
|
14504
14617
|
length
|
|
14505
14618
|
};
|
|
14506
14619
|
}
|
|
14507
|
-
function attr(
|
|
14620
|
+
function attr(attrs2, name) {
|
|
14508
14621
|
var _a;
|
|
14509
|
-
const m =
|
|
14622
|
+
const m = attrs2.match(
|
|
14510
14623
|
new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
|
|
14511
14624
|
);
|
|
14512
14625
|
return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
|
|
@@ -14814,8 +14927,8 @@ function radiusLengths(value) {
|
|
|
14814
14927
|
return lengths.filter((v) => {
|
|
14815
14928
|
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14816
14929
|
if (v === "50%") return false;
|
|
14817
|
-
const
|
|
14818
|
-
return !(v.endsWith("px") && Number.isFinite(
|
|
14930
|
+
const px2 = parseFloat(v);
|
|
14931
|
+
return !(v.endsWith("px") && Number.isFinite(px2) && px2 >= 500);
|
|
14819
14932
|
});
|
|
14820
14933
|
}
|
|
14821
14934
|
function collect($) {
|
|
@@ -15221,6 +15334,7 @@ var CompileError = class extends Error {
|
|
|
15221
15334
|
analyzeEmail,
|
|
15222
15335
|
analyzeImages,
|
|
15223
15336
|
analyzeSpam,
|
|
15337
|
+
arcsizeToRadius,
|
|
15224
15338
|
auditEmail,
|
|
15225
15339
|
caveatApplies,
|
|
15226
15340
|
checkAccessibility,
|
|
@@ -15252,6 +15366,8 @@ var CompileError = class extends Error {
|
|
|
15252
15366
|
heuristicTokenCount,
|
|
15253
15367
|
parseColor,
|
|
15254
15368
|
relativeLuminance,
|
|
15369
|
+
renderOutlookBranch,
|
|
15370
|
+
resolveMsoBranch,
|
|
15255
15371
|
rgbToOklab,
|
|
15256
15372
|
simulateDarkMode,
|
|
15257
15373
|
structuralWarnings,
|
|
@@ -15259,6 +15375,7 @@ var CompileError = class extends Error {
|
|
|
15259
15375
|
transformForAllClients,
|
|
15260
15376
|
transformForClient,
|
|
15261
15377
|
validateLinks,
|
|
15378
|
+
vmlToCss,
|
|
15262
15379
|
warningsForClient,
|
|
15263
15380
|
wcagGrade
|
|
15264
15381
|
});
|