@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 CHANGED
@@ -10,7 +10,7 @@
10
10
  [![CI](https://github.com/emailens/engine/actions/workflows/ci.yml/badge.svg)](https://github.com/emailens/engine/actions/workflows/ci.yml)
11
11
  [![npm](https://img.shields.io/npm/v/@emailens/engine)](https://www.npmjs.com/package/@emailens/engine)
12
12
  [![license](https://img.shields.io/npm/l/@emailens/engine)](./LICENSE)
13
- [![tests](https://img.shields.io/badge/tests-910%20passing-brightgreen)]()
13
+ [![tests](https://img.shields.io/badge/tests-1036%20passing-brightgreen)]()
14
14
  [![node](https://img.shields.io/node/v/@emailens/engine)](https://nodejs.org/)
15
15
  [![MCP](https://img.shields.io/badge/MCP-Server-blue)](https://github.com/emailens/mcp)
16
16
  [![GitHub stars](https://img.shields.io/github/stars/emailens/engine?style=flat)](https://github.com/emailens/engine/stargazers)
@@ -106,11 +106,14 @@ const { code } = await generateAiFix({
106
106
 
107
107
  ## What It Catches
108
108
 
109
- 10 analysis engines, one `auditEmail()` call.
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 downleveled = downlevelCSS(html);
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(downleveled, CLIENT_CONFIGS[clientId], framework)
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 px = mediaPx(m[2]);
13320
- if (px === null) continue;
13321
- if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
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 px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
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 px >= 18 || px >= 14 && bold;
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 attrs = ["href", "src", "alt"];
14267
- for (const attr2 of attrs) {
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(attrs, name) {
14625
+ function attr(attrs2, name) {
14508
14626
  var _a;
14509
- const m = attrs.match(
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 px = parseFloat(v);
14818
- return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
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
  });