@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 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
 
@@ -1,5 +1,5 @@
1
- import { w as InputFormat } from '../types-BFhPXh3c.cjs';
2
- export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-BWIfwG7U.cjs';
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.
@@ -1,5 +1,5 @@
1
- import { w as InputFormat } from '../types-BFhPXh3c.js';
2
- export { C as CompileError, a as CompileReactEmailOptions, S as SandboxStrategy, c as compileReactEmail } from '../react-email-D24HkW2Z.js';
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 downleveled = downlevelCSS(html);
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, attr) {
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[attr]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr];
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 px = mediaPx(m[2]);
13318
- if (px === null) continue;
13319
- if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
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 px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
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 px >= 18 || px >= 14 && bold;
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 attrs = ["href", "src", "alt"];
14265
- for (const attr of attrs) {
14266
- const value = $(el).attr(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:${attr}:${variable}`;
14388
+ const key = `attr:${attr2}:${variable}`;
14274
14389
  if (seen.has(key)) continue;
14275
14390
  seen.add(key);
14276
- const loc = locOfAttr(el, attr);
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 ${attr} attribute.`,
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 attr = $el.attr("width");
14307
- if (attr && /^\d+$/.test(attr.trim())) return parseInt(attr.trim(), 10);
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(/&nbsp;|\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 px = parseFloat(v);
14629
- return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
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
  });