@emailens/engine 0.11.1 → 0.11.3
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 +135 -13
- 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 +131 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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) {
|
|
@@ -10593,8 +10706,13 @@ function transformForAllClients(html, framework) {
|
|
|
10593
10706
|
throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
|
|
10594
10707
|
}
|
|
10595
10708
|
const downleveled = downlevelCSS(html);
|
|
10709
|
+
const outlookSource = /<!--\[if/i.test(html) ? downlevelCSS(renderOutlookBranch(html)) : downleveled;
|
|
10596
10710
|
return Object.keys(CLIENT_CONFIGS).map(
|
|
10597
|
-
(clientId) => applyTransform(
|
|
10711
|
+
(clientId) => applyTransform(
|
|
10712
|
+
clientId === "outlook-windows-legacy" ? outlookSource : downleveled,
|
|
10713
|
+
CLIENT_CONFIGS[clientId],
|
|
10714
|
+
framework
|
|
10715
|
+
)
|
|
10598
10716
|
);
|
|
10599
10717
|
}
|
|
10600
10718
|
|
|
@@ -13316,9 +13434,9 @@ function mediaApplies(prelude, ctx) {
|
|
|
13316
13434
|
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13317
13435
|
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13318
13436
|
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13319
|
-
const
|
|
13320
|
-
if (
|
|
13321
|
-
if (m[1] === "max" ? ctx.width >
|
|
13437
|
+
const px2 = mediaPx(m[2]);
|
|
13438
|
+
if (px2 === null) continue;
|
|
13439
|
+
if (m[1] === "max" ? ctx.width > px2 : ctx.width < px2) return false;
|
|
13322
13440
|
}
|
|
13323
13441
|
return true;
|
|
13324
13442
|
}
|
|
@@ -13539,10 +13657,10 @@ function resolveBackground($, el, cascade) {
|
|
|
13539
13657
|
function isLargeText(size, weight) {
|
|
13540
13658
|
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13541
13659
|
if (!match) return false;
|
|
13542
|
-
const
|
|
13660
|
+
const px2 = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13543
13661
|
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13544
13662
|
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13545
|
-
return
|
|
13663
|
+
return px2 >= 18 || px2 >= 14 && bold;
|
|
13546
13664
|
}
|
|
13547
13665
|
function inherited($, el, cascade, prop) {
|
|
13548
13666
|
var _a, _b;
|
|
@@ -14263,8 +14381,8 @@ function checkTemplateVariablesFromDom($, source) {
|
|
|
14263
14381
|
const attrSelectors = ["[href]", "[src]", "[alt]"];
|
|
14264
14382
|
for (const sel of attrSelectors) {
|
|
14265
14383
|
$(sel).each((_, el) => {
|
|
14266
|
-
const
|
|
14267
|
-
for (const attr2 of
|
|
14384
|
+
const attrs2 = ["href", "src", "alt"];
|
|
14385
|
+
for (const attr2 of attrs2) {
|
|
14268
14386
|
const value = $(el).attr(attr2);
|
|
14269
14387
|
if (!value) continue;
|
|
14270
14388
|
for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
|
|
@@ -14504,9 +14622,9 @@ function locAt(source, offset, length) {
|
|
|
14504
14622
|
length
|
|
14505
14623
|
};
|
|
14506
14624
|
}
|
|
14507
|
-
function attr(
|
|
14625
|
+
function attr(attrs2, name) {
|
|
14508
14626
|
var _a;
|
|
14509
|
-
const m =
|
|
14627
|
+
const m = attrs2.match(
|
|
14510
14628
|
new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
|
|
14511
14629
|
);
|
|
14512
14630
|
return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
|
|
@@ -14814,8 +14932,8 @@ function radiusLengths(value) {
|
|
|
14814
14932
|
return lengths.filter((v) => {
|
|
14815
14933
|
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14816
14934
|
if (v === "50%") return false;
|
|
14817
|
-
const
|
|
14818
|
-
return !(v.endsWith("px") && Number.isFinite(
|
|
14935
|
+
const px2 = parseFloat(v);
|
|
14936
|
+
return !(v.endsWith("px") && Number.isFinite(px2) && px2 >= 500);
|
|
14819
14937
|
});
|
|
14820
14938
|
}
|
|
14821
14939
|
function collect($) {
|
|
@@ -15221,6 +15339,7 @@ var CompileError = class extends Error {
|
|
|
15221
15339
|
analyzeEmail,
|
|
15222
15340
|
analyzeImages,
|
|
15223
15341
|
analyzeSpam,
|
|
15342
|
+
arcsizeToRadius,
|
|
15224
15343
|
auditEmail,
|
|
15225
15344
|
caveatApplies,
|
|
15226
15345
|
checkAccessibility,
|
|
@@ -15252,6 +15371,8 @@ var CompileError = class extends Error {
|
|
|
15252
15371
|
heuristicTokenCount,
|
|
15253
15372
|
parseColor,
|
|
15254
15373
|
relativeLuminance,
|
|
15374
|
+
renderOutlookBranch,
|
|
15375
|
+
resolveMsoBranch,
|
|
15255
15376
|
rgbToOklab,
|
|
15256
15377
|
simulateDarkMode,
|
|
15257
15378
|
structuralWarnings,
|
|
@@ -15259,6 +15380,7 @@ var CompileError = class extends Error {
|
|
|
15259
15380
|
transformForAllClients,
|
|
15260
15381
|
transformForClient,
|
|
15261
15382
|
validateLinks,
|
|
15383
|
+
vmlToCss,
|
|
15262
15384
|
warningsForClient,
|
|
15263
15385
|
wcagGrade
|
|
15264
15386
|
});
|