@emailens/engine 0.11.5 → 0.11.7

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-1087%20passing-brightgreen)]()
13
+ [![tests](https://img.shields.io/badge/tests-1108%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)
package/dist/index.cjs CHANGED
@@ -9887,8 +9887,10 @@ function arcsizeToRadius(arcsize, width, height) {
9887
9887
  if (!Number.isFinite(pct)) return 0;
9888
9888
  return Math.min(100, Math.max(0, pct)) / 100 * (Math.min(width, height) / 2);
9889
9889
  }
9890
+ var DOWNLEVEL_REVEALED = /<!--\[if[^\]]*!\s*(?:mso|vml)[^\]]*\]>\s*<!--(?:>|\s*-->)([\s\S]*?)<!--\s*<!\[endif\]-->/gi;
9891
+ var DOWNLEVEL_HIDDEN = /<!--\[if(?![^\]]*!)[^\]]*(?:mso|vml)[^\]]*\]>([\s\S]*?)<!\[endif\]-->/gi;
9890
9892
  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");
9893
+ return html.replace(DOWNLEVEL_REVEALED, "").replace(DOWNLEVEL_HIDDEN, "$1");
9892
9894
  }
9893
9895
  function vmlToCss(html) {
9894
9896
  let out = html;
@@ -11620,6 +11622,7 @@ function fromHtml(html, empty, fn, options) {
11620
11622
  }
11621
11623
 
11622
11624
  // src/analyze.ts
11625
+ var WORD_ENGINE_CLIENT = "outlook-windows-legacy";
11623
11626
  var HTML_ELEMENT_SELECTORS = {
11624
11627
  "<style>": "style",
11625
11628
  "<link>": "link[rel='stylesheet']",
@@ -11960,7 +11963,32 @@ function analyzeEmail(html, framework, options) {
11960
11963
  throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
11961
11964
  }
11962
11965
  const $ = loadHtml(html, options);
11963
- return analyzeEmailFromDom($, framework, (options == null ? void 0 : options.positions) ? html : void 0);
11966
+ return analyzeAllBranches($, html, framework, (options == null ? void 0 : options.positions) ? html : void 0);
11967
+ }
11968
+ function analyzeAllBranches($, html, framework, source) {
11969
+ return withOutlookBranch(html, analyzeEmailFromDom($, framework, source), framework);
11970
+ }
11971
+ function withOutlookBranch(html, warnings, framework) {
11972
+ if (!/<!--\[if/i.test(html)) return warnings;
11973
+ let branchWarnings;
11974
+ try {
11975
+ const resolved = resolveMsoBranch(html);
11976
+ if (resolved === html) return warnings;
11977
+ branchWarnings = analyzeEmailFromDom(loadHtml(resolved), framework).filter((w) => w.client === WORD_ENGINE_CLIENT);
11978
+ } catch (e) {
11979
+ return warnings;
11980
+ }
11981
+ const kept = warnings.filter((w) => w.client !== WORD_ENGINE_CLIENT);
11982
+ const firstPassWord = warnings.filter((w) => w.client === WORD_ENGINE_CLIENT);
11983
+ const byKey = new Map(firstPassWord.map((w) => [`${w.property}:${w.severity}`, w]));
11984
+ const merged = branchWarnings.map((w) => {
11985
+ var _a;
11986
+ return (_a = byKey.get(`${w.property}:${w.severity}`)) != null ? _a : w;
11987
+ });
11988
+ for (const w of firstPassWord) {
11989
+ if (!merged.some((m) => m.property === w.property && m.severity === w.severity)) merged.push(w);
11990
+ }
11991
+ return [...kept, ...merged];
11964
11992
  }
11965
11993
  function getFixType(prop) {
11966
11994
  return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
@@ -14426,6 +14454,7 @@ function checkTemplateVariables(html, options) {
14426
14454
 
14427
14455
  // src/overflow-checker.ts
14428
14456
  var csstree7 = __toESM(require("css-tree"), 1);
14457
+ var RESPONSIVE_MIN_WIDTH = 320;
14429
14458
  function fixedPxWidth($el) {
14430
14459
  const style = $el.attr("style") || "";
14431
14460
  const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
@@ -14560,6 +14589,30 @@ function checkOverflowFromDom($, source) {
14560
14589
  }, loc ? { loc, locs: [loc] } : {}));
14561
14590
  }
14562
14591
  }
14592
+ if (!/@media/i.test($.html())) {
14593
+ let widest = 0;
14594
+ let widestLabel = "";
14595
+ let widestLoc;
14596
+ $("table, td, div").each((_, el) => {
14597
+ const $el = $(el);
14598
+ const width = fixedPxWidth($el);
14599
+ if (width === null || width < RESPONSIVE_MIN_WIDTH) return;
14600
+ if (isFluid($el.attr("style") || "")) return;
14601
+ if (width <= widest) return;
14602
+ widest = width;
14603
+ widestLabel = `<${(el.tagName || "element").toLowerCase()}>`;
14604
+ const fromStyle = /(?:^|[;\s])width\s*:\s*\d+px/i.test($el.attr("style") || "");
14605
+ widestLoc = locOfAttr(el, fromStyle ? "style" : "width");
14606
+ });
14607
+ if (widest > 0) {
14608
+ issues.push(__spreadValues({
14609
+ rule: "no-responsive-rules",
14610
+ severity: "warning",
14611
+ message: `${widestLabel} is a fixed ${widest}px wide and the email has no @media rules, so nothing tells a narrow screen what to do.`,
14612
+ detail: `Each mobile client then decides for itself: one scales the layout to fit, another leaves it to scroll, a third stretches the container while any child with a px max-width stays put and leaves gutters. Give the container width:100% with max-width:${widest}px, and add a breakpoint for the parts that need to stack.`
14613
+ }, widestLoc ? { loc: widestLoc, locs: [widestLoc] } : {}));
14614
+ }
14615
+ }
14563
14616
  return { hasOverflow: issues.length > 0, issues };
14564
14617
  }
14565
14618
  function checkOverflow(html, options) {
@@ -14666,13 +14719,70 @@ function checkLooseText(issues, seen, open, inner, source) {
14666
14719
  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>.`
14667
14720
  }, locAt(source, open.offset, open.length)));
14668
14721
  }
14722
+ var GHOST_TAGS = /* @__PURE__ */ new Set(["table", "tr", "td", "th", "tbody", "thead", "tfoot"]);
14723
+ function ghostTags(blocks) {
14724
+ const tags = [];
14725
+ const re = /<(\/?)(table|tr|td|th|tbody|thead|tfoot)\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
14726
+ for (const block of blocks) {
14727
+ let m;
14728
+ re.lastIndex = 0;
14729
+ while ((m = re.exec(block.inner)) !== null) {
14730
+ tags.push({
14731
+ name: m[2].toLowerCase(),
14732
+ closing: m[1] === "/",
14733
+ selfClosing: m[4] === "/",
14734
+ attrs: m[3],
14735
+ offset: block.offset + m.index,
14736
+ length: m[0].length
14737
+ });
14738
+ }
14739
+ }
14740
+ return tags;
14741
+ }
14742
+ function checkGhostTables(issues, seen, blocks, source) {
14743
+ var _a;
14744
+ const tags = ghostTags(blocks);
14745
+ if (tags.length === 0) return;
14746
+ const opens = /* @__PURE__ */ new Map();
14747
+ const strays = [];
14748
+ for (const tag of tags) {
14749
+ if (tag.selfClosing) continue;
14750
+ const list = (_a = opens.get(tag.name)) != null ? _a : [];
14751
+ if (tag.closing) {
14752
+ if (list.length === 0) strays.push(tag);
14753
+ else list.pop();
14754
+ } else list.push(tag);
14755
+ opens.set(tag.name, list);
14756
+ }
14757
+ for (const [name, list] of opens) {
14758
+ if (!GHOST_TAGS.has(name)) continue;
14759
+ for (const tag of list.slice(0, 1)) {
14760
+ add(issues, seen, `ghost-open:${name}`, withLoc({
14761
+ rule: "ghost-table-unbalanced",
14762
+ severity: "error",
14763
+ message: `<${name}> is opened inside an Outlook conditional comment and never closed.`,
14764
+ detail: `The closing half of a ghost wrapper lives in its own <!--[if mso]> block, usually much further down the file, and is easy to lose in an edit. Outlook is left with a table that never ends, so it swallows the rest of the email; every other client sees only comment nodes and renders normally, which is why this survives review. Add the matching </${name}> in a closing conditional block.`
14765
+ }, locAt(source, tag.offset, tag.length)));
14766
+ }
14767
+ }
14768
+ for (const tag of strays.slice(0, 1)) {
14769
+ add(issues, seen, `ghost-stray:${tag.name}`, withLoc({
14770
+ rule: "ghost-table-unbalanced",
14771
+ severity: "error",
14772
+ message: `Closing </${tag.name}> inside an Outlook conditional comment has no matching opening tag.`,
14773
+ detail: `The opening half of the ghost wrapper is missing or was removed. Outlook receives a stray closing tag, which ends a table it never started and can truncate the layout from that point.`
14774
+ }, locAt(source, tag.offset, tag.length)));
14775
+ }
14776
+ }
14669
14777
  function checkVml(html, options) {
14670
14778
  if (!html || !html.trim()) return EMPTY_VML;
14671
14779
  const source = (options == null ? void 0 : options.positions) ? html : void 0;
14672
- const tags = vmlTags(msoBlocks(html));
14673
- if (tags.length === 0) return EMPTY_VML;
14780
+ const blocks = msoBlocks(html);
14781
+ const tags = vmlTags(blocks);
14674
14782
  const issues = [];
14675
14783
  const seen = /* @__PURE__ */ new Map();
14784
+ checkGhostTables(issues, seen, blocks, source);
14785
+ if (tags.length === 0) return { hasVml: false, issues };
14676
14786
  const openShapes = [];
14677
14787
  let groupDepth = 0;
14678
14788
  for (const tag of tags) {
@@ -15105,7 +15215,7 @@ function runAudit($, html, framework, options) {
15105
15215
  var _a;
15106
15216
  const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
15107
15217
  const source = (options == null ? void 0 : options.positions) ? html : void 0;
15108
- const warnings = skip.has("compatibility") ? [] : analyzeEmailFromDom($, framework, source);
15218
+ const warnings = skip.has("compatibility") ? [] : analyzeAllBranches($, html, framework, source);
15109
15219
  const scores = skip.has("compatibility") ? {} : generateCompatibilityScore(warnings);
15110
15220
  const spam = skip.has("spam") ? EMPTY_SPAM : analyzeSpamFromDom($, options == null ? void 0 : options.spam);
15111
15221
  const links = skip.has("links") ? EMPTY_LINKS : validateLinksFromDom($);
@@ -15270,7 +15380,7 @@ function createSession(html, options) {
15270
15380
  return runAudit($, html, framework, __spreadProps(__spreadValues({}, opts), { positions: options == null ? void 0 : options.positions }));
15271
15381
  },
15272
15382
  analyze() {
15273
- return analyzeEmailFromDom($, framework, source);
15383
+ return analyzeAllBranches($, html, framework, source);
15274
15384
  },
15275
15385
  score(warnings) {
15276
15386
  return generateCompatibilityScore(warnings);